Skip to content

Commit a1ebf13

Browse files
authored
Auto categorizer (#709)
* Creating auto-categorizer helper using ML * Refactoring to avoid code duplication related to categories * Refactoring to avoid multiple calls to GetCurrentUserAsync when syncing or importing multiple transactions * Adding code to instantiate and use autoCategorizer * Adding Auto-Categorizer settings on client side * Fixing problems in client, adding new service and WebApi controller to allow training and storing ML model in the DB as a large object * Minor fixes for autocategorized, adding migration * Fixes to Auto-categorizer pull request based on feedback
1 parent 21b3bd1 commit a1ebf13

34 files changed

+2295
-158
lines changed

client/public/locales/en-us/translation.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,5 +413,18 @@
413413
"view_and_restore_deleted_assets": "View and restore deleted assets.",
414414
"view_and_restore_deleted_transactions": "View and restore deleted transactions.",
415415
"welcome_to": "Welcome to",
416-
"widget_no_items_configured_message": "No items are configured for this widget."
416+
"widget_no_items_configured_message": "No items are configured for this widget.",
417+
"enable_auto_categorizer": "Auto-Categorizer",
418+
"enable_auto_categorizer_description": "Auto-Categorizer can attempt to guess the category of a new uncategorized transaction based on your previous transactions.",
419+
"enable_auto_categorizer_warning": "NOTE: Categories set on a transaction by matching Automatic Rules overwrite categories picked by Auto-Categorizer.",
420+
"enable_auto_categorizer_button_disabled_hover": "You must train Auto-Categorizer with existing transactions before it can be enabled.",
421+
"train_auto_categorizer": "Train Auto-Categorizer",
422+
"train_auto_categorizer_description": "You may pick a start and/or end date for the transactions used for training. If both dates are left empty, all your transactions are used. Training can take a few minutes depending on how many transactions are in your chosen interval.",
423+
"train_auto_categorizer_last_trained": "Auto-Categorizer last trained on {{lastTrained}}, using transactions from {{trainDataStartDate}} to {{trainDataEndDate}}.",
424+
"train_auto_categorizer_not_trained": "Auto-Categorizer not trained yet.",
425+
"train_auto_categorizer_button": "Train",
426+
"start_date": "Start date",
427+
"end_date": "End date",
428+
"train_auto_categorizer_dates_error": "Start date must be before end date.",
429+
"train_auto_categorizer_success": "Auto-Categorizer training successful"
417430
}

client/src/app/Authorized/PageContent/Settings/AdvancedSettings/AdvancedSettings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Stack } from "@mantine/core";
22
import ForceSyncLookbackPeriod from "./ForceSyncLookbackPeriod/ForceSyncLookbackPeriod";
33
import DisableBuiltInTransactionCategories from "./DisableBuiltInTransactionCategories/DisableBuiltInTransactionCategories";
4+
import EnableAutoCategorizer from "./EnableAutoCategorizer/EnableAutoCategorizer";
45
import Card from "~/components/core/Card/Card";
56
import PrimaryText from "~/components/core/Text/PrimaryText/PrimaryText";
67
import { useTranslation } from "react-i18next";
8+
import TrainAutoCategorizerModal from "./TrainAutoCategorizerModal/TrainAutoCategorizerModal";
79

810
const AdvancedSettings = () => {
911
const { t } = useTranslation();
@@ -14,6 +16,8 @@ const AdvancedSettings = () => {
1416
<PrimaryText size="lg">{t("advanced_settings")}</PrimaryText>
1517
<DisableBuiltInTransactionCategories />
1618
<ForceSyncLookbackPeriod />
19+
<EnableAutoCategorizer />
20+
<TrainAutoCategorizerModal />
1721
</Stack>
1822
</Card>
1923
);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Button, Skeleton, Stack, Tooltip } from "@mantine/core";
2+
import { notifications } from "@mantine/notifications";
3+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4+
import { AxiosResponse } from "axios";
5+
import React from "react";
6+
import { useAuth } from "~/providers/AuthProvider/AuthProvider";
7+
import { translateAxiosError } from "~/helpers/requests";
8+
import {
9+
IUserSettings,
10+
IUserSettingsUpdateRequest,
11+
} from "~/models/userSettings";
12+
import PrimaryText from "~/components/core/Text/PrimaryText/PrimaryText";
13+
import DimmedText from "~/components/core/Text/DimmedText/DimmedText";
14+
import { useTranslation } from "react-i18next";
15+
16+
const EnableAutoCategorizer = (): React.ReactNode => {
17+
const { t } = useTranslation();
18+
const { request } = useAuth();
19+
20+
const userSettingsQuery = useQuery({
21+
queryKey: ["userSettings"],
22+
queryFn: async (): Promise<IUserSettings | undefined> => {
23+
const res: AxiosResponse = await request({
24+
url: "/api/userSettings",
25+
method: "GET",
26+
});
27+
28+
if (res.status === 200) {
29+
return res.data as IUserSettings;
30+
}
31+
32+
return undefined;
33+
},
34+
});
35+
36+
const queryClient = useQueryClient();
37+
const doUpdateUserSettings = useMutation({
38+
mutationFn: async (updatedUserSettings: IUserSettingsUpdateRequest) =>
39+
await request({
40+
url: "/api/userSettings",
41+
method: "PUT",
42+
data: updatedUserSettings,
43+
}),
44+
onSuccess: async () => {
45+
await queryClient.invalidateQueries({ queryKey: ["userSettings"] });
46+
},
47+
onError: (error: any) => {
48+
notifications.show({
49+
color: "var(--button-color-destructive)",
50+
message: translateAxiosError(error),
51+
});
52+
},
53+
});
54+
55+
if (userSettingsQuery.isPending) {
56+
return <Skeleton height={75} radius="md" />;
57+
}
58+
59+
const button = <Button
60+
bg={
61+
userSettingsQuery.data?.enableAutoCategorizer
62+
? ""
63+
: "var(--button-color-destructive)"
64+
}
65+
variant="primary"
66+
size="xs"
67+
onClick={
68+
userSettingsQuery.data?.autoCategorizerModelOID != null
69+
? () => {
70+
doUpdateUserSettings.mutate({
71+
enableAutoCategorizer:
72+
!userSettingsQuery.data?.enableAutoCategorizer,
73+
} as IUserSettingsUpdateRequest);
74+
}
75+
: (event) => event.preventDefault() // Prevent click when disabled
76+
}
77+
disabled={ userSettingsQuery.data?.autoCategorizerModelOID == null }
78+
>
79+
{userSettingsQuery.data?.enableAutoCategorizer
80+
? t("enabled")
81+
: t("disabled")}
82+
</Button>;
83+
84+
// If the button is disabled, we need to wrap it in a tooltip.
85+
const tooltip = <Tooltip label={t("enable_auto_categorizer_button_disabled_hover")}>
86+
{button}
87+
</Tooltip>
88+
89+
return (
90+
<Stack gap="0.25rem">
91+
<PrimaryText size="sm">
92+
{t("enable_auto_categorizer")}
93+
</PrimaryText>
94+
<DimmedText size="xs">
95+
{t("enable_auto_categorizer_description")}
96+
</DimmedText>
97+
<DimmedText size="xs">
98+
{t("enable_auto_categorizer_warning")}
99+
</DimmedText>
100+
{userSettingsQuery.data?.autoCategorizerModelOID == null
101+
? tooltip
102+
: button
103+
}
104+
</Stack>
105+
);
106+
};
107+
108+
export default EnableAutoCategorizer;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Button, Stack } from "@mantine/core";
2+
import { useField } from "@mantine/form";
3+
import { useDisclosure } from "@mantine/hooks";
4+
import { notifications } from "@mantine/notifications";
5+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6+
import { AxiosError, AxiosResponse } from "axios";
7+
import React from "react";
8+
import { useAuth } from "~/providers/AuthProvider/AuthProvider";
9+
import { translateAxiosError } from "~/helpers/requests";
10+
import Modal from "~/components/core/Modal/Modal";
11+
import PrimaryText from "~/components/core/Text/PrimaryText/PrimaryText";
12+
import DateInput from "~/components/core/Input/DateInput/DateInput";
13+
import { useTranslation } from "react-i18next";
14+
import { ITrainAutoCategorizer as ITrainAutoCategorizerRequest } from "~/models/autoCategorizer";
15+
import { IUserSettings } from "~/models/userSettings";
16+
import DimmedText from "~/components/core/Text/DimmedText/DimmedText";
17+
18+
const TrainAutoCategorizerModal = (): React.ReactNode => {
19+
const [opened, { open, close }] = useDisclosure(false);
20+
21+
const { t } = useTranslation();
22+
23+
const startDateField = useField<Date | null>({
24+
initialValue: null,
25+
});
26+
const endDateField = useField<Date | null>({
27+
initialValue: null,
28+
});
29+
30+
const { request } = useAuth();
31+
32+
const userSettingsQuery = useQuery({
33+
queryKey: ["userSettings"],
34+
queryFn: async (): Promise<IUserSettings | undefined> => {
35+
const res: AxiosResponse = await request({
36+
url: "/api/userSettings",
37+
method: "GET",
38+
});
39+
40+
if (res.status === 200) {
41+
return res.data as IUserSettings;
42+
}
43+
44+
return undefined;
45+
},
46+
});
47+
48+
const queryClient = useQueryClient();
49+
const doTrainAutoCategorizer = useMutation({
50+
mutationFn: async (trainAutoCategorizer: ITrainAutoCategorizerRequest) =>
51+
await request({
52+
url: "/api/trainAutoCategorizer",
53+
method: "POST",
54+
data: trainAutoCategorizer,
55+
}),
56+
onSuccess: async () => {
57+
notifications.show({
58+
message: t("train_auto_categorizer_success")
59+
});
60+
close();
61+
await queryClient.invalidateQueries({ queryKey: ["userSettings"] });
62+
},
63+
onError: (error: AxiosError) => {
64+
notifications.show({
65+
message: translateAxiosError(error),
66+
color: "var(--button-color-destructive)",
67+
});
68+
},
69+
});
70+
71+
const onSubmit = () => {
72+
const startDate = startDateField.getValue();
73+
const endDate = endDateField.getValue();
74+
if (
75+
startDate != null &&
76+
endDate != null &&
77+
startDate > endDate
78+
) {
79+
notifications.show({
80+
color: "var(--button-color-destructive)",
81+
message: t("train_auto_categorizer_dates_error"),
82+
});
83+
return;
84+
}
85+
86+
doTrainAutoCategorizer.mutate({
87+
startDate: startDateField.getValue(),
88+
endDate: endDateField.getValue(),
89+
} as ITrainAutoCategorizerRequest);
90+
};
91+
92+
return (
93+
<>
94+
<PrimaryText size="sm">
95+
{t("train_auto_categorizer")}
96+
</PrimaryText>
97+
<DimmedText size="xs">
98+
{t("train_auto_categorizer_description")}
99+
</DimmedText>
100+
<DimmedText size="xs">
101+
{ userSettingsQuery.data?.autoCategorizerLastTrained != null
102+
? t("train_auto_categorizer_last_trained", {
103+
lastTrained: userSettingsQuery.data?.autoCategorizerLastTrained,
104+
trainDataStartDate: userSettingsQuery.data?.autoCategorizerModelStartDate,
105+
trainDataEndDate: userSettingsQuery.data?.autoCategorizerModelEndDate
106+
})
107+
: t("train_auto_categorizer_not_trained")
108+
}
109+
</DimmedText>
110+
<Button size="input-sm" onClick={open}>
111+
{t("train_auto_categorizer_button")}
112+
</Button>
113+
<Modal
114+
opened={opened}
115+
onClose={close}
116+
title={<PrimaryText>{t("train_auto_categorizer")}</PrimaryText>}
117+
>
118+
<Stack gap="0.25rem">
119+
<DateInput
120+
label={<PrimaryText size="sm">{t("start_date")}</PrimaryText>}
121+
placeholder={t("select_a_date")}
122+
{...startDateField.getInputProps()}
123+
elevation={0}
124+
clearable
125+
/>
126+
<DateInput
127+
label={<PrimaryText size="sm">{t("end_date")}</PrimaryText>}
128+
placeholder={t("select_a_date")}
129+
{...endDateField.getInputProps()}
130+
elevation={0}
131+
clearable
132+
/>
133+
<Button
134+
mt="0.25rem"
135+
onClick={onSubmit}
136+
loading={doTrainAutoCategorizer.isPending}
137+
>
138+
{t("submit")}
139+
</Button>
140+
</Stack>
141+
</Modal>
142+
</>
143+
);
144+
};
145+
146+
export default TrainAutoCategorizerModal;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ITrainAutoCategorizer {
2+
startDate?: Date;
3+
endDate?: Date;
4+
}

client/src/models/userSettings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export interface IUserSettings {
44
budgetWarningThreshold: number;
55
forceSyncLookbackMonths: number;
66
disableBuiltInTransactionCategories: boolean;
7+
enableAutoCategorizer: boolean;
8+
autoCategorizerModelOID?: number;
9+
autoCategorizerLastTrained?: Date;
10+
autoCategorizerModelStartDate?: Date;
11+
autoCategorizerModelEndDate?: Date;
712
}
813

914
export interface IUserSettingsUpdateRequest {
@@ -12,6 +17,11 @@ export interface IUserSettingsUpdateRequest {
1217
budgetWarningThreshold?: number;
1318
forceSyncLookbackMonths?: number;
1419
disableBuiltInTransactionCategories?: boolean;
20+
enableAutoCategorizer?: boolean;
21+
autoCategorizerModelOID?: number;
22+
autoCategorizerLastTrained?: Date;
23+
autoCategorizerModelStartDate?: Date;
24+
autoCategorizerModelEndDate?: Date;
1525
}
1626

1727
export class LanguageItem {

server/BudgetBoard.Database/BudgetBoard.Database.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1616
</PackageReference>
1717
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
18+
<PackageReference Include="Npgsql" Version="10.0.1" />
1819
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
1920
</ItemGroup>
2021

0 commit comments

Comments
 (0)