Skip to content

Commit

Permalink
feat: Account suggestions now considers description to provide better…
Browse files Browse the repository at this point in the history
… suggestions
  • Loading branch information
kajyr committed Dec 14, 2021
1 parent e2dbc7b commit 94de593
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 18 deletions.
65 changes: 65 additions & 0 deletions backend/suggestions/get-sorted-accounts-matching-descr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Transaction } from 'pta-tools';

import getSortedAccountsMatchingDescr from './get-sorted-accounts-matching-descr';

function mockTransaction(account, description?): Transaction {
return {
description,
date: new Date("2020-01-01"),
entries: [{ account, amount: 3 }, { account: "Assets:Cash" }],
};
}

describe("getSortedAccountsMatchingDescr", () => {
const journal = [
mockTransaction("Expenses:Bananas", "Supermarket"),
mockTransaction("Expenses:Breakfast", "Bar"),
mockTransaction("Expenses:Bananas", "Supermarket"),
mockTransaction("Expenses:Bananas", "Supermarket"),
mockTransaction("Expenses:Bananas", "Supermarket"),
mockTransaction("Expenses:Breakfast", "cafeteria"),
mockTransaction("Expenses:Lunch", "bar"),
mockTransaction("Assets:Bank"),
mockTransaction("Expenses:Groceries", "Supermarket"),
mockTransaction("Expenses:Groceries", "Supermarket"),
];

test("Returns sorted account list", () => {
// Breakfast is first because it matches bar
// even if it occurres less than Bananas
// Bananas goes before Bank because it is used more often
expect(
getSortedAccountsMatchingDescr(journal, "b", "bar", undefined)
).toEqual(["Expenses:Breakfast", "Expenses:Bananas", "Assets:Bank"]);
});

test("With no query, just sort by descr", () => {
// Breakfast is first because it matches bar
// even if it occurres less than Bananas
expect(
getSortedAccountsMatchingDescr(journal, undefined, "bar", undefined)
).toEqual([
"Assets:Cash", // This is used in every mock, so first
"Expenses:Breakfast",
"Expenses:Lunch", // this is used less, but matches bar
"Expenses:Bananas", // this is used more, but does not match descr
"Expenses:Groceries",
"Assets:Bank",
]);
});

test("With no query, just sort by descr AND sorting key", () => {
// Breakfast is first because it matches bar
// even if it occurres less than Bananas
expect(
getSortedAccountsMatchingDescr(journal, undefined, "bar", "expenses")
).toEqual([
"Expenses:Breakfast",
"Expenses:Lunch", // this is used less, but matches bar
"Expenses:Bananas", // this is used more, but does not match descr
"Expenses:Groceries",
"Assets:Cash", // This is used in every mock, but does not match sorting
"Assets:Bank",
]);
});
});
70 changes: 70 additions & 0 deletions backend/suggestions/get-sorted-accounts-matching-descr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { isPosting, isTransaction, Journal, Transaction } from 'pta-tools';

type Pivot = {
account: string;
descrMatch: number;
sortMatch: boolean;
uses: number;
};

function getAccounts(trx: Transaction) {
const accounts: string[] = [];
for (const entry of trx.entries) {
if (isPosting(entry)) {
accounts.push(entry.account);
}
}
return accounts;
}

function getSortedAccountsMatchingDescr(
journal: Journal,
query: string | undefined,
description: string | undefined,
sort: string | undefined
): string[] {
const descr = description?.toLowerCase();
const q = query?.toLowerCase();
const s = sort?.toLowerCase();

const map = {} as Record<string, Pivot>;

for (const trx of journal) {
if (!isTransaction(trx)) {
continue;
}
const descrMatch =
descr && trx.description?.toLowerCase().startsWith(descr) ? 1 : 0;

for (const account of getAccounts(trx)) {
const accName = account.toLowerCase();
const sortMatch = !!s && accName.startsWith(s);

if (!q || accName.includes(q)) {
if (!map[account]) {
map[account] = { account, descrMatch, sortMatch, uses: 1 };
} else {
map[account].descrMatch += descrMatch;
map[account].sortMatch = map[account].sortMatch || sortMatch;
map[account].uses++;
}
}
}
}

// Sort by sorting matching
// Then by descr matching
// Then by usage
// Then by name
const sorted = Object.values(map).sort(
(a, b) =>
Number(b.sortMatch) - Number(a.sortMatch) ||
b.descrMatch - a.descrMatch ||
b.uses - a.uses ||
a.account.localeCompare(b.account)
);

return sorted.map((v) => v.account);
}

export default getSortedAccountsMatchingDescr;
21 changes: 21 additions & 0 deletions backend/suggestions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { isTransaction, Journal } from 'pta-tools';
import { readFile } from '../dal';
import { sortByOccurrence } from '../helpers/array';

import getSortedAccountsMatchingDescr from './get-sorted-accounts-matching-descr';

function getPayees(trxs: Journal): string[] {
return trxs.reduce((acc, trx) => {
if (isTransaction(trx) && trx.description) {
Expand Down Expand Up @@ -41,6 +43,25 @@ export default function (fastify, opts, done) {
return sortByOccurrence(list).splice(0, 5);
},
},
{
method: "GET",
url: `/api/s/account/:query?`,
handler: async function (request) {
const data = await readFile();

const { query } = request.params;
const { description, sort } = request.query;

const list = getSortedAccountsMatchingDescr(
data.journal,
query,
description,
sort
);

return list.splice(0, 5);
},
},
];

for (const route of routes) {
Expand Down
33 changes: 21 additions & 12 deletions frontend/src/atoms/async-autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import { useQuery } from 'react-query';

import { Autocomplete, AutocompleteProps } from '@mantine/core';

type Props = Omit<AutocompleteProps, "data"> & { endpoint: string };
type Props = Omit<AutocompleteProps, "data"> & {
endpoint: string;
params?: string;
};

type Suggestions = string[];

const AsyncAutocomplete: FC<Props> = ({ endpoint, ...props }) => {
const { isLoading, error, data } = useQuery<Suggestions>(
[endpoint, props.value],
const AsyncAutocomplete: FC<Props> = ({ endpoint, params, ...props }) => {
const [isOpen, setOpen] = useState(false);
const { data } = useQuery<Suggestions>(
[endpoint, props.value, params],
() => {
const val = props.value || "";
return fetch(`${endpoint}/${props.value}`).then((res) => res.json());

return Promise.resolve([]);
return fetch(
`${endpoint}/${props.value}${params ? `?${params}` : ""}`
).then((res) => res.json());
},
{ retry: false }
{ retry: false, enabled: isOpen }
);
return (
<Autocomplete
{...props}
data={data || []}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
);

return <Autocomplete {...props} data={data || []} />;
};

export default AsyncAutocomplete;
20 changes: 14 additions & 6 deletions frontend/src/pages/dashboard/entry-row.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { FC } from 'react';

import AsyncAutocomplete from 'atoms/async-autocomplete';

import { Autocomplete, Button, createStyles, Group, Space, TextInput } from '@mantine/core';

import { Comment, isComment, Posting } from 'pta-tools';
Expand All @@ -24,9 +26,10 @@ const EntryRow: FC<{
amountPlaceholder: string | null;
canDelete: boolean;
commodities: string[];
description: string | undefined;
entry: Posting | Comment;
suggestedCommodity: string | undefined;
removeRow: () => void;
suggestedCommodity: string | undefined;
updateRow: (field: string, value: string) => void;
}> = ({
accounts,
Expand All @@ -37,21 +40,26 @@ const EntryRow: FC<{
suggestedCommodity,
removeRow,
updateRow,
description,
}) => {
const { classes } = useStyles();
if (isComment(entry)) {
return null;
}

const params = ["sort=expenses"];
if (description) {
params.push(`description=${description}`);
}

return (
<Group className={classes.wrapper}>
<Autocomplete
<AsyncAutocomplete
endpoint="/api/s/account"
params={params.join("&")}
placeholder="Account"
value={entry.account}
style={{ flex: 3 }}
data={accounts}
filter={(value, item) =>
item.value.toLowerCase().includes(value.toLowerCase().trim())
}
onChange={(value) => updateRow("account", value)}
/>
<TextInput
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ const Dashboard: FC<{ journal: Api.BootstrapResponse }> = ({ journal }) => {
/>
{values.entries.map((entry, i) => (
<EntryRow
description={values.description}
accounts={journal.accounts}
canDelete={i !== 0}
commodities={journal.commodities}
Expand Down

0 comments on commit 94de593

Please sign in to comment.