diff --git a/src/core/Auth/Layout/withAuthLayout.tsx b/src/core/Auth/Layout/withAuthLayout.tsx
index 1cbcd0cb..7c1886be 100644
--- a/src/core/Auth/Layout/withAuthLayout.tsx
+++ b/src/core/Auth/Layout/withAuthLayout.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { Navigate } from 'react-router-dom';
+import { Navigate, useLocation } from 'react-router-dom';
import { FullPageLoader } from '@/components/Loading';
import { useAuthContext } from '../Auth.context';
import { AuthLayout } from './Auth.layout';
@@ -12,10 +12,19 @@ import { NotVerified } from './NotVerified.component';
*/
export function withAuthLayout(Component: React.ComponentType
) {
return function WrappedComponent(props: P & { isAuthenticated?: boolean }) {
+ const { pathname } = useLocation();
const { loading, session } = useAuthContext();
+ const loginRedirectUrl = React.useMemo(() => {
+ const query = new URLSearchParams({
+ callbackUrl: pathname,
+ });
+ return '/sign-in?' + query;
+ }, [pathname]);
+
if (loading) return ;
- if (!session) return ;
+
+ if (!session) return ;
return (
{session.isVerified ? : }
);
diff --git a/src/core/Budget/BalanceWidget.component.tsx b/src/core/Budget/BalanceWidget.component.tsx
new file mode 100644
index 00000000..86a9011d
--- /dev/null
+++ b/src/core/Budget/BalanceWidget.component.tsx
@@ -0,0 +1,116 @@
+import { Card, type TCardProps } from '@/components/Base';
+import { formatBalance } from '@/utils';
+import { type TDescription } from '@budgetbuddyde/types';
+import { Box, Divider, List, ListItem, ListItemText, Typography } from '@mui/material';
+import React from 'react';
+
+export type TBalanceWidgetData = {
+ type: 'INCOME' | 'SPENDINGS';
+ label: string;
+ description: TDescription;
+ amount: number;
+};
+
+export type TBalanceWidgetProps = {
+ cardProps?: TCardProps;
+ income?: TBalanceWidgetData[];
+ spendings?: TBalanceWidgetData[];
+};
+
+export const BalanceWidget: React.FC = ({ cardProps, income, spendings }) => {
+ const totalIncome: number = React.useMemo(() => {
+ return income?.reduce((acc, { amount }) => acc + amount, 0) ?? 0;
+ }, [income]);
+
+ const totalSpendings: number = React.useMemo(() => {
+ return spendings?.reduce((acc, { amount }) => acc + amount, 0) ?? 0;
+ }, [spendings]);
+
+ const totalBalance: number = React.useMemo(() => {
+ return totalIncome + totalSpendings;
+ }, [totalIncome, totalSpendings]);
+
+ const groupData = React.useCallback((data: TBalanceWidgetData[]): TBalanceWidgetData[] => {
+ if (data.length === 0) return [];
+ return Object.values(
+ data.reduce((acc: { [key: string]: TBalanceWidgetData }, item: TBalanceWidgetData) => {
+ const { label, amount, type } = item;
+ if (acc[label]) {
+ acc[label].amount += amount;
+ } else {
+ acc[label] = { label, description: 'No information', type, amount };
+ }
+ return acc;
+ }, {})
+ );
+ }, []);
+
+ const groupedIncome = React.useMemo(() => {
+ return groupData(income ?? []);
+ }, [income]);
+
+ const groupedSpendings = React.useMemo(() => {
+ return groupData(spendings ?? []);
+ }, [spendings]);
+
+ return (
+
+
+
+ Balance
+
+
+
+ Income} dense>
+ {groupedIncome.length > 0 &&
+ groupedIncome.map(({ amount, label }) => (
+ {formatBalance(amount)}}
+ disablePadding
+ >
+
+
+ ))}
+
+
+
+ Spendings} dense>
+ {groupedSpendings.length > 0 &&
+ groupedSpendings.map(({ amount, label }) => (
+ {formatBalance(amount)}}
+ disablePadding
+ >
+
+
+ ))}
+
+
+
+
+ {[
+ { label: 'Income', amount: totalIncome },
+ { label: 'Spendings', amount: totalSpendings },
+ { label: 'Balance', amount: totalBalance },
+ ].map(({ label, amount }, idx, list) => (
+
+ {formatBalance(amount)}
+
+ }
+ disablePadding
+ >
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/core/Budget/index.ts b/src/core/Budget/index.ts
index eff0d7aa..17cbf9f9 100644
--- a/src/core/Budget/index.ts
+++ b/src/core/Budget/index.ts
@@ -9,3 +9,4 @@ export * from './BudgetList.component';
export * from './CategoryBudget.component';
export * from './EditBudgetDrawer.component';
export * from './StatsWrapper.component';
+export * from './BalanceWidget.component';
diff --git a/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx b/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx
index 5587b23c..3ed05f8c 100644
--- a/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx
+++ b/src/core/Category/Autocomplete/CategoryAutocomplete.component.tsx
@@ -12,8 +12,9 @@ import {
Typography,
createFilterOptions,
} from '@mui/material';
-import { CreateCategoryAlert, useFetchCategories } from '../';
+import { CategoryService, CreateCategoryAlert, useFetchCategories } from '../';
import { StyledAutocompleteOption } from '@/components/Base';
+import { useFetchTransactions } from '@/core/Transaction';
export type TCategoryInputOption = {
label: string;
@@ -74,22 +75,29 @@ export const CategoryAutocomplete: React.FC = ({
}) => {
const id = React.useId();
const navigate = useNavigate();
- const { loading: loadingCategories, categories, error } = useFetchCategories();
+ const { loading: loadingTransactions, transactions } = useFetchTransactions();
+ const { loading: loadingCategories, categories, error: categoryError } = useFetchCategories();
- if (!loadingCategories && categories.length === 0 && !error) {
- if (error) {
- return (
-
- Error
- {String(error)}
-
- );
- } else return ;
- } else if (!loadingCategories && error) console.error('CategoryAutocomplete: ' + error);
+ const options: TCategoryInputOption[] = React.useMemo(() => {
+ return CategoryService.sortAutocompleteOptionsByTransactionUsage(categories, transactions);
+ }, [categories, transactions]);
+
+ if (categoryError) {
+ console.log('CategoryAutocomplete.component.tsx: categoryError: ', categoryError);
+ return (
+
+ Error
+ {String(!categoryError)}
+
+ );
+ }
+ if (!loadingCategories && categories.length === 0) {
+ return ;
+ }
return (
({ label: item.name, value: item.id }))}
+ options={options}
onChange={(event, value, _details) => {
if (!value) return;
const categoryNameExists = categories.some((category) => category.name === value.label);
@@ -123,9 +131,9 @@ export const CategoryAutocomplete: React.FC = ({
required={required}
/>
)}
- disabled={loadingCategories}
+ disabled={loadingCategories || loadingTransactions}
isOptionEqualToValue={(option, value) => option.value === value.value}
- loading={loadingCategories}
+ loading={loadingCategories || loadingTransactions}
sx={sx}
/>
);
diff --git a/src/core/Category/Category.service.ts b/src/core/Category/Category.service.ts
index b7dd51d1..843c613a 100644
--- a/src/core/Category/Category.service.ts
+++ b/src/core/Category/Category.service.ts
@@ -6,12 +6,15 @@ import {
type TCreateCategoryPayload,
type TDeleteCategoryPayload,
type TUpdateCategoryPayload,
- TDeleteCategoryResponsePayload,
+ type TDeleteCategoryResponsePayload,
ZDeleteCategoryResponsePayload,
+ TTransaction,
} from '@budgetbuddyde/types';
import { prepareRequestOptions } from '@/utils';
import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util';
import { IAuthContext } from '../Auth';
+import { TCategoryInputOption } from '.';
+import { subDays } from 'date-fns';
export class CategoryService {
private static host =
@@ -104,4 +107,51 @@ export class CategoryService {
return [null, error as Error];
}
}
+
+ /**
+ * Sorts the autocomplete options for categories based on transaction usage.
+ *
+ * @param transactions - The list of transactions.
+ * @param days - The number of days to consider for transaction usage. Default is 30 days.
+ * @returns The sorted autocomplete options for categories.
+ */
+ static sortAutocompleteOptionsByTransactionUsage(
+ categories: TCategory[],
+ transactions: TTransaction[],
+ days: number = 30
+ ): TCategoryInputOption[] {
+ const uniqueCatgegories = categories;
+ const now = new Date();
+ const startDate = subDays(now, days);
+ const categoryFrequencyMap: { [categoryId: string]: number } = {};
+
+ let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate);
+ if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50);
+ pastNTransactions.forEach(({ category: { id }, processedAt }) => {
+ if (processedAt >= startDate && processedAt <= now) {
+ categoryFrequencyMap[id] = (categoryFrequencyMap[id] || 0) + 1;
+ }
+ });
+
+ return this.getAutocompleteOptions(
+ uniqueCatgegories
+ .map((category) => ({
+ ...category,
+ frequency: categoryFrequencyMap[category.id] || -1,
+ }))
+ .sort((a, b) => b.frequency - a.frequency)
+ );
+ }
+
+ /**
+ * Returns an array of autocomplete options for the given categories.
+ * @param categories - The array of categories.
+ * @returns An array of autocomplete options.
+ */
+ static getAutocompleteOptions(categories: TCategory[]): TCategoryInputOption[] {
+ return categories.map(({ id, name }) => ({
+ label: name,
+ value: id,
+ }));
+ }
}
diff --git a/src/core/Category/__tests__/Category.service.test.ts b/src/core/Category/__tests__/Category.service.test.ts
new file mode 100644
index 00000000..f9d5891d
--- /dev/null
+++ b/src/core/Category/__tests__/Category.service.test.ts
@@ -0,0 +1,94 @@
+import { subDays } from 'date-fns';
+import { CategoryService } from '../Category.service';
+import { type TCategory, type TTransaction } from '@budgetbuddyde/types';
+import { type TCategoryInputOption } from '../Autocomplete';
+
+describe('sortAutocompleteOptionsByTransactionUsage', () => {
+ it('should return an empty array if categories is empty', () => {
+ const categories: TCategory[] = [];
+ const transactions: TTransaction[] = [];
+ const result = CategoryService.sortAutocompleteOptionsByTransactionUsage(
+ categories,
+ transactions
+ );
+ expect(result).toEqual([]);
+ });
+
+ it('should return autocomplete options sorted by transaction usage', () => {
+ const categories = [
+ { id: 1, name: 'Category 1' },
+ { id: 2, name: 'Category 2' },
+ { id: 3, name: 'Category 3' },
+ ] as TCategory[];
+ const transactions = [
+ { category: categories[0], processedAt: new Date() },
+ { category: categories[1], processedAt: new Date() },
+ { category: categories[0], processedAt: new Date() },
+ { category: categories[2], processedAt: new Date() },
+ ] as TTransaction[];
+ const result = CategoryService.sortAutocompleteOptionsByTransactionUsage(
+ categories,
+ transactions
+ );
+
+ const expected: TCategoryInputOption[] = [
+ { value: 1, label: 'Category 1' },
+ { value: 2, label: 'Category 2' },
+ { value: 3, label: 'Category 3' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ it('should return autocomplete options sorted by transaction usage within the specified days', () => {
+ const categories = [
+ { id: 1, name: 'Category 1' },
+ { id: 2, name: 'Category 2' },
+ { id: 3, name: 'Category 3' },
+ ] as TCategory[];
+ const transactions = [
+ { category: categories[0], processedAt: subDays(new Date(), 10) },
+ { category: categories[1], processedAt: subDays(new Date(), 20) },
+ { category: categories[0], processedAt: subDays(new Date(), 5) },
+ { category: categories[2], processedAt: subDays(new Date(), 15) },
+ ] as TTransaction[];
+ const result = CategoryService.sortAutocompleteOptionsByTransactionUsage(
+ categories,
+ transactions,
+ 15
+ );
+
+ const expected: TCategoryInputOption[] = [
+ { value: 1, label: 'Category 1' },
+ { value: 3, label: 'Category 3' },
+ { value: 2, label: 'Category 2' },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ it('should return autocomplete options sorted by transaction usage with default days', () => {
+ const categories = [
+ { id: 1, name: 'Category 1' },
+ { id: 2, name: 'Category 2' },
+ { id: 3, name: 'Category 3' },
+ ] as TCategory[];
+ const transactions = [
+ { category: categories[0], processedAt: subDays(new Date(), 10) },
+ { category: categories[2], processedAt: subDays(new Date(), 20) },
+ { category: categories[0], processedAt: subDays(new Date(), 5) },
+ { category: categories[1], processedAt: subDays(new Date(), 15) },
+ { category: categories[2], processedAt: subDays(new Date(), 31) },
+ { category: categories[2], processedAt: subDays(new Date(), 31) },
+ ] as TTransaction[];
+ const result = CategoryService.sortAutocompleteOptionsByTransactionUsage(
+ categories,
+ transactions
+ );
+
+ const expected: TCategoryInputOption[] = [
+ { value: 1, label: 'Category 1' },
+ { value: 2, label: 'Category 2' },
+ { value: 3, label: 'Category 3' },
+ ];
+ expect(result).toEqual(expected);
+ });
+});
diff --git a/src/core/Filter/Filter.store.ts b/src/core/Filter/Filter.store.ts
index e69e2230..3d22f2f6 100644
--- a/src/core/Filter/Filter.store.ts
+++ b/src/core/Filter/Filter.store.ts
@@ -9,8 +9,8 @@ export type TFilters = {
paymentMethods: TPaymentMethod['id'][] | null;
startDate: Date;
endDate: Date;
- priceFrom: number;
- priceTo: number;
+ priceFrom: number | null;
+ priceTo: number | null;
};
export const DEFAULT_FILTERS: TFilters = {
@@ -19,8 +19,8 @@ export const DEFAULT_FILTERS: TFilters = {
paymentMethods: null,
startDate: getFirstDayOfMonth(subMonths(new Date(), 12)),
endDate: getLastDayOfMonth(),
- priceFrom: -99999,
- priceTo: 99999,
+ priceFrom: null,
+ priceTo: null,
};
export interface IFilterStore {
diff --git a/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx b/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx
index 36ea8200..23c43bb5 100644
--- a/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx
+++ b/src/core/PaymentMethod/Autocomplete/PaymentMethodAutocomplete.component.tsx
@@ -16,6 +16,8 @@ import { useFetchPaymentMethods } from '../useFetchPaymentMethods.hook';
import { CreatePaymentMethodAlert } from '../CreatePaymentMethodAlert.component';
import { StyledAutocompleteOption } from '@/components/Base';
import { getNameFromLabel } from '@/core/Category';
+import { PaymentMethodService } from '../PaymentMethod.service';
+import { useFetchTransactions } from '@/core/Transaction';
export type TPaymentMethodInputOption = {
label: string;
@@ -61,25 +63,39 @@ export const PaymentMethodAutocomplete: React.FC
}) => {
const id = React.useId();
const navigate = useNavigate();
- const { loading: loadingPaymentMethods, paymentMethods, error } = useFetchPaymentMethods();
+ const { loading: loadingTransactions, transactions } = useFetchTransactions();
+ const {
+ loading: loadingPaymentMethods,
+ paymentMethods,
+ error: paymentMethodError,
+ } = useFetchPaymentMethods();
- if (!loadingPaymentMethods && paymentMethods.length === 0 && !error) {
- if (error) {
- return (
-
- Error
- {String(error)}
-
- );
- } else return ;
- } else if (!loadingPaymentMethods && error) console.error('PaymentMethodAutocomplete: ' + error);
+ const options: TPaymentMethodInputOption[] = React.useMemo(() => {
+ return PaymentMethodService.sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods,
+ transactions
+ );
+ }, [paymentMethods, transactions]);
+
+ if (paymentMethodError) {
+ console.log(
+ 'PaymentMethodAutocomplete.component.tsx: paymentMethodError: ',
+ paymentMethodError
+ );
+ return (
+
+ Error
+ {String(!paymentMethodError)}
+
+ );
+ }
+ if (!loadingPaymentMethods && paymentMethods.length === 0) {
+ return ;
+ }
return (
({
- label: `${item.name} ${PaymentMethodLabelSeperator} ${item.provider}`,
- value: item.id,
- }))}
+ options={options}
onChange={(event, value) => {
if (!value) return;
const paymentMethodExists = paymentMethods.some(
@@ -115,9 +131,9 @@ export const PaymentMethodAutocomplete: React.FC
required={required}
/>
)}
- disabled={loadingPaymentMethods}
+ disabled={loadingPaymentMethods || loadingTransactions}
isOptionEqualToValue={(option, value) => option.value === value.value}
- loading={loadingPaymentMethods}
+ loading={loadingPaymentMethods || loadingTransactions}
sx={sx}
/>
);
diff --git a/src/core/PaymentMethod/PaymentMethod.service.ts b/src/core/PaymentMethod/PaymentMethod.service.ts
index 503051c5..893bc57b 100644
--- a/src/core/PaymentMethod/PaymentMethod.service.ts
+++ b/src/core/PaymentMethod/PaymentMethod.service.ts
@@ -9,10 +9,13 @@ import {
type TUser,
type TDeletePaymentMethodResponsePayload,
ZDeletePaymentMethodResponsePayload,
+ type TTransaction,
} from '@budgetbuddyde/types';
import { prepareRequestOptions } from '@/utils';
import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util';
import { IAuthContext } from '../Auth';
+import { PaymentMethodLabelSeperator, type TPaymentMethodInputOption } from './Autocomplete';
+import { subDays } from 'date-fns';
export class PaymentMethodService {
private static host =
@@ -105,4 +108,61 @@ export class PaymentMethodService {
return [null, error as Error];
}
}
+
+ /**
+ * Sorts the autocomplete options for payment-methods based on transaction usage.
+ *
+ * @param transactions - The list of transactions.
+ * @param days - The number of days to consider for transaction usage. Default is 30 days.
+ * @returns The sorted autocomplete options for payment-methods.
+ */
+ static sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods: TPaymentMethod[],
+ transactions: TTransaction[],
+ days: number = 30
+ ): TPaymentMethodInputOption[] {
+ const uniquePaymentMethods = paymentMethods;
+ const now = new Date();
+ const startDate = subDays(now, days);
+ const paymentMethodFrequencyMap: { [paymentMethodId: string]: number } = {};
+
+ let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate);
+ if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50);
+ pastNTransactions.forEach(({ paymentMethod: { id }, processedAt }) => {
+ if (processedAt >= startDate && processedAt <= now) {
+ paymentMethodFrequencyMap[id] = (paymentMethodFrequencyMap[id] || 0) + 1;
+ }
+ });
+
+ return this.getAutocompleteOptions(
+ uniquePaymentMethods
+ .map((paymentMethod) => ({
+ ...paymentMethod,
+ frequency: paymentMethodFrequencyMap[paymentMethod.id] || -1,
+ }))
+ .sort((a, b) => b.frequency - a.frequency)
+ );
+ }
+
+ /**
+ * Returns an array of autocomplete options for the given payment-methods.
+ * @param paymentMethods - The array of payment-methods.
+ * @returns An array of autocomplete options.
+ */
+ static getAutocompleteOptions(paymentMethods: TPaymentMethod[]): TPaymentMethodInputOption[] {
+ return paymentMethods.map(({ id, name, provider }) => ({
+ label: this.getAutocompleteLabel({ name, provider }),
+ value: id,
+ }));
+ }
+
+ /**
+ * Returns the autocomplete label for a payment method.
+ * The autocomplete label is a combination of the payment method's name and provider.
+ * @param paymentMethod - The payment method object.
+ * @returns The autocomplete label.
+ */
+ static getAutocompleteLabel(paymentMethod: Pick): string {
+ return `${paymentMethod.name} ${PaymentMethodLabelSeperator} ${paymentMethod.provider}`;
+ }
}
diff --git a/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts b/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts
new file mode 100644
index 00000000..a2046784
--- /dev/null
+++ b/src/core/PaymentMethod/__tests__/PaymentMethod.service.test.ts
@@ -0,0 +1,94 @@
+import { subDays } from 'date-fns';
+import { PaymentMethodService } from '../PaymentMethod.service';
+import { type TPaymentMethod, type TTransaction } from '@budgetbuddyde/types';
+import { type TPaymentMethodInputOption } from '../Autocomplete';
+
+describe('sortAutocompleteOptionsByTransactionUsage', () => {
+ it('should return an empty array if payment-methods is empty', () => {
+ const paymentMethods: TPaymentMethod[] = [];
+ const transactions: TTransaction[] = [];
+ const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods,
+ transactions
+ );
+ expect(result).toEqual([]);
+ });
+
+ it('should return autocomplete options sorted by transaction usage', () => {
+ const paymentMethods = [
+ { id: 1, name: 'Payment Method 1', provider: 'Provider 1' },
+ { id: 2, name: 'Payment Method 2', provider: 'Provider 2' },
+ { id: 3, name: 'Payment Method 3', provider: 'Provider 3' },
+ ] as TPaymentMethod[];
+ const transactions = [
+ { paymentMethod: paymentMethods[0], processedAt: new Date() },
+ { paymentMethod: paymentMethods[1], processedAt: new Date() },
+ { paymentMethod: paymentMethods[0], processedAt: new Date() },
+ { paymentMethod: paymentMethods[2], processedAt: new Date() },
+ ] as TTransaction[];
+ const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods,
+ transactions
+ );
+
+ const expected: TPaymentMethodInputOption[] = [
+ { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) },
+ { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) },
+ { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ it('should return autocomplete options sorted by transaction usage within the specified days', () => {
+ const paymentMethods = [
+ { id: 1, name: 'Payment Method 1', provider: 'Provider 1' },
+ { id: 2, name: 'Payment Method 2', provider: 'Provider 2' },
+ { id: 3, name: 'Payment Method 3', provider: 'Provider 3' },
+ ] as TPaymentMethod[];
+ const transactions = [
+ { paymentMethod: paymentMethods[1], processedAt: subDays(new Date(), 20) },
+ { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 10) },
+ { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 5) },
+ { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 15) },
+ ] as TTransaction[];
+ const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods,
+ transactions,
+ 15
+ );
+
+ const expected: TPaymentMethodInputOption[] = [
+ { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) },
+ { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) },
+ { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) },
+ ];
+ expect(result).toEqual(expected);
+ });
+
+ it('should return autocomplete options sorted by transaction usage with default days', () => {
+ const paymentMethods = [
+ { id: 1, name: 'Payment Method 1', provider: 'Provider 1' },
+ { id: 2, name: 'Payment Method 2', provider: 'Provider 2' },
+ { id: 3, name: 'Payment Method 3', provider: 'Provider 3' },
+ ] as TPaymentMethod[];
+ const transactions = [
+ { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 10) },
+ { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 20) },
+ { paymentMethod: paymentMethods[0], processedAt: subDays(new Date(), 5) },
+ { paymentMethod: paymentMethods[1], processedAt: subDays(new Date(), 15) },
+ { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 31) },
+ { paymentMethod: paymentMethods[2], processedAt: subDays(new Date(), 31) },
+ ] as TTransaction[];
+ const result = PaymentMethodService.sortAutocompleteOptionsByTransactionUsage(
+ paymentMethods,
+ transactions
+ );
+
+ const expected: TPaymentMethodInputOption[] = [
+ { value: 1, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[0]) },
+ { value: 2, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[1]) },
+ { value: 3, label: PaymentMethodService.getAutocompleteLabel(paymentMethods[2]) },
+ ];
+ expect(result).toEqual(expected);
+ });
+});
diff --git a/src/core/Transaction/CreateTransactionDrawer.component.tsx b/src/core/Transaction/CreateTransactionDrawer.component.tsx
index c2af6414..92128523 100644
--- a/src/core/Transaction/CreateTransactionDrawer.component.tsx
+++ b/src/core/Transaction/CreateTransactionDrawer.component.tsx
@@ -16,14 +16,14 @@ import { useAuthContext } from '../Auth';
import { useSnackbarContext } from '../Snackbar';
import { TransactionService, useFetchTransactions } from '.';
import { CategoryAutocomplete, getCategoryFromList, useFetchCategories } from '../Category';
-import { ReceiverAutocomplete } from '@/components/Base';
+import { ReceiverAutocomplete, type TAutocompleteOption } from '@/components/Base';
import {
PaymentMethodAutocomplete,
getPaymentMethodFromList,
useFetchPaymentMethods,
} from '../PaymentMethod';
import {
- TCreateTransactionPayload,
+ type TCreateTransactionPayload,
type TTransaction,
ZCreateTransactionPayload,
} from '@budgetbuddyde/types';
@@ -67,6 +67,13 @@ export const CreateTransactionDrawer: React.FC =
date: new Date(),
});
+ const receiverOptions: TAutocompleteOption[] = React.useMemo(() => {
+ return TransactionService.getUniqueReceivers(transactions).map((receiver) => ({
+ label: receiver,
+ value: receiver,
+ }));
+ }, [transactions]);
+
const handler: ICreateTransactionDrawerHandler = {
onClose() {
onChangeOpen(false);
@@ -202,10 +209,7 @@ export const CreateTransactionDrawer: React.FC =
sx={FormStyle}
id="receiver"
label="Receiver"
- options={TransactionService.getUniqueReceivers(transactions).map((receiver) => ({
- label: receiver,
- value: receiver,
- }))}
+ options={receiverOptions}
defaultValue={transaction?.receiver}
onValueChange={(value) => handler.onReceiverChange(String(value))}
required
diff --git a/src/core/Transaction/Transaction.service.ts b/src/core/Transaction/Transaction.service.ts
index 14a2f09e..be0340ad 100644
--- a/src/core/Transaction/Transaction.service.ts
+++ b/src/core/Transaction/Transaction.service.ts
@@ -12,7 +12,7 @@ import {
type TDeleteTransactionResponsePayload,
ZDeleteTransactionResponsePayload,
} from '@budgetbuddyde/types';
-import { format, isSameMonth } from 'date-fns';
+import { format, isSameMonth, subDays } from 'date-fns';
import { isRunningInProdEnv } from '@/utils/isRunningInProdEnv.util';
import { prepareRequestOptions } from '@/utils';
import { type IAuthContext } from '../Auth';
@@ -112,12 +112,34 @@ export class TransactionService {
}
/**
- * Retrieves the unique receivers of a set of transactions.
- * @param transactions - The transactions to retrieve the unique receivers from.
- * @returns An array containing the unique receivers.
+ * Returns an array of unique receivers from the given transactions within a specified number of days.
+ * The receivers are sorted based on their frequency of occurrence in the transactions.
+ *
+ * @param transactions - The array of transactions.
+ * @param days - The number of days to consider for filtering the transactions. Default is 30 days.
+ * @returns An array of unique receivers sorted by frequency of occurrence.
*/
- static getUniqueReceivers(transactions: TTransaction[]): string[] {
- return [...new Set(transactions.map(({ receiver }) => receiver))];
+ static getUniqueReceivers(transactions: TTransaction[], days: number = 30): string[] {
+ const uniqueReceivers = Array.from(new Set(transactions.map(({ receiver }) => receiver)));
+ const now = new Date();
+ const startDate = subDays(now, days);
+ const receiverFrequencyMap: { [receiver: string]: number } = {};
+
+ let pastNTransactions = transactions.filter(({ processedAt }) => processedAt >= startDate);
+ if (pastNTransactions.length < 1) pastNTransactions = transactions.slice(0, 50);
+ pastNTransactions.forEach(({ receiver, processedAt }) => {
+ if (processedAt >= startDate && processedAt <= now) {
+ receiverFrequencyMap[receiver] = (receiverFrequencyMap[receiver] || 0) + 1;
+ }
+ });
+
+ return uniqueReceivers
+ .map((receiver) => ({
+ receiver,
+ frequency: receiverFrequencyMap[receiver] || -1,
+ }))
+ .sort((a, b) => b.frequency - a.frequency)
+ .map(({ receiver }) => receiver);
}
/**
diff --git a/src/core/Transaction/__tests___/Transaction.service.test.ts b/src/core/Transaction/__tests___/Transaction.service.test.ts
new file mode 100644
index 00000000..4e891817
--- /dev/null
+++ b/src/core/Transaction/__tests___/Transaction.service.test.ts
@@ -0,0 +1,46 @@
+import { subDays } from 'date-fns';
+import { TransactionService } from '../Transaction.service';
+import { TTransaction } from '@budgetbuddyde/types';
+
+describe('getUniqueReceivers', () => {
+ it('should return an empty array if transactions is empty', () => {
+ const transactions: TTransaction[] = [];
+ const result = TransactionService.getUniqueReceivers(transactions);
+ expect(result).toEqual([]);
+ });
+
+ it('should return an array of unique receivers', () => {
+ const transactions = [
+ { receiver: 'John', processedAt: new Date() },
+ { receiver: 'Jane', processedAt: new Date() },
+ { receiver: 'John', processedAt: new Date() },
+ { receiver: 'Alice', processedAt: new Date() },
+ ] as TTransaction[];
+ const result = TransactionService.getUniqueReceivers(transactions);
+ expect(result).toEqual(['John', 'Jane', 'Alice']);
+ });
+
+ it('should return an array of unique receivers within the specified days', () => {
+ const transactions = [
+ { receiver: 'John', processedAt: subDays(new Date(), 10) },
+ { receiver: 'Jane', processedAt: subDays(new Date(), 20) },
+ { receiver: 'John', processedAt: subDays(new Date(), 5) },
+ { receiver: 'Alice', processedAt: subDays(new Date(), 15) },
+ ] as TTransaction[];
+ const result = TransactionService.getUniqueReceivers(transactions, 15);
+ expect(result).toEqual(['John', 'Alice', 'Jane']);
+ });
+
+ it('should return an array of unique receivers sorted by frequency', () => {
+ const transactions = [
+ { receiver: 'John', processedAt: new Date() },
+ { receiver: 'Jane', processedAt: new Date() },
+ { receiver: 'John', processedAt: new Date() },
+ { receiver: 'Alice', processedAt: new Date() },
+ { receiver: 'Alice', processedAt: new Date() },
+ { receiver: 'Alice', processedAt: new Date() },
+ ] as TTransaction[];
+ const result = TransactionService.getUniqueReceivers(transactions);
+ expect(result).toEqual(['Alice', 'John', 'Jane']);
+ });
+});
diff --git a/src/routes/Budget.route.tsx b/src/routes/Budget.route.tsx
index a24bc505..1c590975 100644
--- a/src/routes/Budget.route.tsx
+++ b/src/routes/Budget.route.tsx
@@ -1,6 +1,8 @@
+import React from 'react';
import { ContentGrid } from '@/components/Layout';
import { withAuthLayout } from '@/core/Auth/Layout';
import {
+ BalanceWidget,
BudgetList,
BudgetProgressWrapper,
StatsWrapper,
@@ -11,6 +13,7 @@ import { CategoryIncomeChart } from '@/core/Category/Chart/IncomeChart.component
import { Grid } from '@mui/material';
import { DailyTransactionChart } from '@/core/Transaction';
import { CircularProgress } from '@/components/Loading';
+import { SubscriptionService, useFetchSubscriptions } from '@/core/Subscription';
export const DATE_RANGE_INPUT_FORMAT = 'dd.MM';
export type TChartContentType = 'INCOME' | 'SPENDINGS';
@@ -21,11 +24,36 @@ export const ChartContentTypes = [
export const Budgets = () => {
const { budgetProgress, loading: loadingBudgetProgress } = useFetchBudgetProgress();
+ const { loading: loadingSubscriptions, subscriptions } = useFetchSubscriptions();
+
+ const getSubscriptionDataByType = React.useCallback(
+ (type: 'INCOME' | 'SPENDINGS') => {
+ return SubscriptionService.getPlannedBalanceByType(subscriptions, type)
+ .filter(({ paused }) => !paused)
+ .map((item) => ({
+ type: type,
+ label: item.category.name,
+ description: item.description,
+ amount: item.transferAmount,
+ }));
+ },
+ [subscriptions]
+ );
return (
+
+ {!loadingSubscriptions && (
+
+ )}
diff --git a/src/routes/PaymentMethods.route.tsx b/src/routes/PaymentMethods.route.tsx
index efe04429..dfa56373 100644
--- a/src/routes/PaymentMethods.route.tsx
+++ b/src/routes/PaymentMethods.route.tsx
@@ -132,7 +132,7 @@ export const PaymentMethods = () => {
return (
-
+
isLoading={loadingPaymentMethods}
title="Payment Methods"
diff --git a/src/routes/SignIn.route.tsx b/src/routes/SignIn.route.tsx
index 0d978dfb..baeab400 100644
--- a/src/routes/SignIn.route.tsx
+++ b/src/routes/SignIn.route.tsx
@@ -7,10 +7,11 @@ import { Card, PasswordInput } from '@/components/Base';
import { StackedIconButton } from '@/components/StackedIconButton.component';
import { AppLogo } from '@/components/AppLogo.component';
import { withUnauthentificatedLayout } from '@/core/Auth/Layout';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { type TSignInPayload, ZSignInPayload } from '@budgetbuddyde/types';
const SignIn = () => {
+ const location = useLocation();
const navigate = useNavigate();
const { session, setSession } = useAuthContext();
const { showSnackbar } = useSnackbarContext();
@@ -20,27 +21,35 @@ const SignIn = () => {
inputChange: (event: React.ChangeEvent) => {
setForm((prev) => ({ ...prev, [event.target.name]: event.target.value }));
},
- formSubmit: async (event: React.FormEvent) => {
- event.preventDefault();
+ formSubmit: React.useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
- try {
- const parsedForm = ZSignInPayload.safeParse(form);
- if (!parsedForm.success) throw new Error(parsedForm.error.message);
- const payload: TSignInPayload = parsedForm.data;
+ try {
+ const parsedForm = ZSignInPayload.safeParse(form);
+ if (!parsedForm.success) throw new Error(parsedForm.error.message);
+ const payload: TSignInPayload = parsedForm.data;
- const [session, error] = await AuthService.signIn(payload);
- if (error) throw error;
- if (!session) throw new Error('No session returned');
- setSession(session);
- showSnackbar({ message: 'Authentification successfull' });
- navigate('/');
- } catch (error) {
- console.error(error);
- showSnackbar({
- message: error instanceof Error ? error.message : 'Authentification failed',
- });
- }
- },
+ const [session, error] = await AuthService.signIn(payload);
+ if (error) throw error;
+ if (!session) throw new Error('No session returned');
+ setSession(session);
+ showSnackbar({ message: 'Authentification successfull' });
+ if (location.search) {
+ const query = new URLSearchParams(location.search.substring(1));
+ if (query.get('callbackUrl')) navigate(query.get('callbackUrl')!);
+ return;
+ }
+ navigate('/');
+ } catch (error) {
+ console.error(error);
+ showSnackbar({
+ message: error instanceof Error ? error.message : 'Authentification failed',
+ });
+ }
+ },
+ [form, setSession, showSnackbar, navigate, location]
+ ),
};
React.useEffect(() => {
diff --git a/src/utils/filter.util.ts b/src/utils/filter.util.ts
index 0f5bb50a..6670c0dd 100644
--- a/src/utils/filter.util.ts
+++ b/src/utils/filter.util.ts
@@ -46,11 +46,11 @@ export function filterTransactions(
}
if (filter.priceFrom != null) {
- transactions = transactions.filter(({ transferAmount }) => transferAmount >= filter.priceFrom);
+ transactions = transactions.filter(({ transferAmount }) => transferAmount >= filter.priceFrom!);
}
if (filter.priceTo != null) {
- transactions = transactions.filter(({ transferAmount }) => transferAmount <= filter.priceTo);
+ transactions = transactions.filter(({ transferAmount }) => transferAmount <= filter.priceTo!);
}
return transactions;
diff --git a/vite.config.ts b/vite.config.ts
index f65162ee..4ce36843 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,4 +1,5 @@
import { defineConfig, type CommonServerOptions } from 'vite';
+import { ViteEjsPlugin } from 'vite-plugin-ejs';
import path from 'path';
import react from '@vitejs/plugin-react-swc';
// import dns from 'dns';
@@ -39,5 +40,13 @@ export default defineConfig({
build: {
outDir: 'build',
},
- plugins: [react()],
+ plugins: [
+ react(),
+ ViteEjsPlugin((config) => {
+ return {
+ ...config,
+ isProd: config.mode === 'production',
+ };
+ }),
+ ],
});