feat(editor,categories): learned category per place, subscription filled from the matched description
This commit is contained in:
1 parent
92db7d4093
commit
df0e19808f
5 files changed
+111
-60
No files matched your search
+31
-20
@@ -1,28 +1,39 @@
|
|||||||
const KEY = "ffui.budget-map";
|
|
||||||
|
|
||||||
export interface Learned {
|
export interface Learned {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function read(): Record<string, Learned> {
|
function learnedMap(key: string) {
|
||||||
try {
|
const read = (): Record<string, Learned> => {
|
||||||
const value = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
try {
|
||||||
return value && typeof value === "object" ? value : {};
|
const value = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||||
} catch {
|
return value && typeof value === "object" ? value : {};
|
||||||
return {};
|
} catch {
|
||||||
}
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (place: string): Learned | null => read()[place] ?? null;
|
||||||
|
|
||||||
|
const set = (place: string, learned: Learned | null) => {
|
||||||
|
const map = read();
|
||||||
|
if (learned) {
|
||||||
|
map[place] = { id: learned.id, name: learned.name };
|
||||||
|
} else {
|
||||||
|
delete map[place];
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, JSON.stringify(map));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remember = (place: string, learned: Learned) => {
|
||||||
|
const before = get(place);
|
||||||
|
set(place, learned);
|
||||||
|
return () => set(place, before);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { get, remember };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const learnedBudget = (destinationId: string): Learned | null =>
|
export const learnedBudgets = learnedMap("ffui.budget-map");
|
||||||
read()[destinationId] ?? null;
|
|
||||||
|
|
||||||
export function learnBudget(destinationId: string, budget: Learned | null) {
|
export const learnedCategories = learnedMap("ffui.category-map");
|
||||||
const map = read();
|
|
||||||
if (budget) {
|
|
||||||
map[destinationId] = { id: budget.id, name: budget.name };
|
|
||||||
} else {
|
|
||||||
delete map[destinationId];
|
|
||||||
}
|
|
||||||
localStorage.setItem(KEY, JSON.stringify(map));
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,26 @@
|
|||||||
function fill(past: Split, withCounterparty: boolean) {
|
function fill(past: Split, withCounterparty: boolean) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const undo: (() => void)[] = [];
|
const undo: (() => void)[] = [];
|
||||||
|
const put = (text: string, still: () => boolean, clear: () => void) => {
|
||||||
|
parts.push(text);
|
||||||
|
undo.push(() => {
|
||||||
|
if (still()) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const putRef = (field: "bill" | "budget", refId: string, name: string) => {
|
||||||
|
if (withdrawal && !(split[field].id || split[field].name)) {
|
||||||
|
split[field] = { id: refId, name };
|
||||||
|
put(
|
||||||
|
`${field === "bill" ? "subscription" : field} ${name}`,
|
||||||
|
() => split[field].id === refId,
|
||||||
|
() => {
|
||||||
|
split[field] = emptyRef();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
const side = counterparty;
|
const side = counterparty;
|
||||||
if (withCounterparty && side && past.type === form.type) {
|
if (withCounterparty && side && past.type === form.type) {
|
||||||
const pastId = past[`${side}_id`];
|
const pastId = past[`${side}_id`];
|
||||||
@@ -87,33 +107,32 @@
|
|||||||
) {
|
) {
|
||||||
const next = toAccountRef(account);
|
const next = toAccountRef(account);
|
||||||
split[side] = next;
|
split[side] = next;
|
||||||
parts.push(next.name);
|
put(
|
||||||
undo.push(() => {
|
next.name,
|
||||||
if (split[side].id === next.id) {
|
() => split[side].id === next.id,
|
||||||
|
() => {
|
||||||
split[side] = emptyAccount();
|
split[side] = emptyAccount();
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const category = past.category_name ?? "";
|
const category = past.category_name ?? "";
|
||||||
if (category && !split.category.trim()) {
|
if (category && !split.category.trim()) {
|
||||||
split.category = category;
|
split.category = category;
|
||||||
parts.push(`category ${category}`);
|
put(
|
||||||
undo.push(() => {
|
`category ${category}`,
|
||||||
if (split.category === category) {
|
() => split.category === category,
|
||||||
|
() => {
|
||||||
split.category = "";
|
split.category = "";
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
const budget = past.budget_id;
|
if (past.budget_id) {
|
||||||
if (withdrawal && budget && !(split.budget.id || split.budget.name)) {
|
putRef("budget", past.budget_id, past.budget_name ?? "");
|
||||||
split.budget = { id: budget, name: past.budget_name ?? "" };
|
}
|
||||||
parts.push(`budget ${past.budget_name ?? ""}`);
|
const bill = withCounterparty ? catalog.billById(past.bill_id) : undefined;
|
||||||
undo.push(() => {
|
if (bill) {
|
||||||
if (split.budget.id === budget) {
|
putRef("bill", bill.id, bill.attributes.name ?? "");
|
||||||
split.budget = emptyRef();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
filled = {
|
filled = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
TransactionGroup,
|
TransactionGroup,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import { learnedBudget } from "#lib/budgets/map.js";
|
import { learnedBudgets, learnedCategories } from "#lib/budgets/map.js";
|
||||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
@@ -66,6 +66,12 @@ export class Catalog {
|
|||||||
return (this.accounts.data ?? []).find((item) => item.id === id);
|
return (this.accounts.data ?? []).find((item) => item.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
billById(id: string | null | undefined) {
|
||||||
|
return (this.bills.data ?? []).find(
|
||||||
|
(item) => item.id === id && item.attributes.active
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
accountOptions(kinds: string[], query: string): Option[] {
|
accountOptions(kinds: string[], query: string): Option[] {
|
||||||
const items = rank(
|
const items = rank(
|
||||||
this.accountsOf(kinds),
|
this.accountsOf(kinds),
|
||||||
@@ -235,23 +241,41 @@ export class Catalog {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
this.history.set(key, found);
|
this.history.set(key, found);
|
||||||
}
|
}
|
||||||
const learned = learnedBudget(accountId)?.id;
|
const learnedBudget = learnedBudgets.get(accountId)?.id;
|
||||||
const budget = this.budgets.data?.find(
|
const budget =
|
||||||
(item) => item.id === learned && item.attributes.active
|
type === "withdrawal" && side === "destination"
|
||||||
);
|
? this.budgets.data?.find(
|
||||||
if (!budget || type !== "withdrawal" || side !== "destination") {
|
(item) => item.id === learnedBudget && item.attributes.active
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const ownSide = type === "deposit" ? "source" : "destination";
|
||||||
|
const learnedCategory = learnedCategories.get(`${type}:${accountId}`)?.id;
|
||||||
|
const category =
|
||||||
|
side === ownSide
|
||||||
|
? this.categories.data?.find((item) => item.id === learnedCategory)
|
||||||
|
: undefined;
|
||||||
|
if (!(budget || category)) {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
return found.then(
|
return found.then((past) => {
|
||||||
(past) =>
|
const split = past?.split;
|
||||||
past && {
|
if (!split) {
|
||||||
split: {
|
return past;
|
||||||
...past.split,
|
}
|
||||||
|
return {
|
||||||
|
split: {
|
||||||
|
...split,
|
||||||
|
...(budget && {
|
||||||
budget_id: budget.id,
|
budget_id: budget.id,
|
||||||
budget_name: budget.attributes.name,
|
budget_name: budget.attributes.name,
|
||||||
},
|
}),
|
||||||
}
|
...(category && {
|
||||||
);
|
category_id: category.id,
|
||||||
|
category_name: category.attributes.name,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fromJournal(journalId: string): Promise<Recent | null> {
|
async fromJournal(journalId: string): Promise<Recent | null> {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Budget, One } from "#lib/api/types.js";
|
import type { Budget, One } from "#lib/api/types.js";
|
||||||
import { learnBudget, learnedBudget } from "#lib/budgets/map.js";
|
import { learnedBudgets } from "#lib/budgets/map.js";
|
||||||
import {
|
import {
|
||||||
type Place,
|
type Place,
|
||||||
placesOf,
|
placesOf,
|
||||||
@@ -269,12 +269,6 @@
|
|||||||
return { id: made.data.id, name: made.data.attributes.name };
|
return { id: made.data.id, name: made.data.attributes.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
function remember(place: Place, option: { id: string; name: string }) {
|
|
||||||
const before = learnedBudget(place.accountId);
|
|
||||||
learnBudget(place.accountId, option);
|
|
||||||
return () => learnBudget(place.accountId, before);
|
|
||||||
}
|
|
||||||
|
|
||||||
function done(index: number, move: Move) {
|
function done(index: number, move: Move) {
|
||||||
adding = null;
|
adding = null;
|
||||||
const next = rows[index + move];
|
const next = rows[index + move];
|
||||||
@@ -739,12 +733,12 @@
|
|||||||
? "Every expense has a budget."
|
? "Every expense has a budget."
|
||||||
: `Every expense in ${period.label} has a budget.`}
|
: `Every expense in ${period.label} has a budget.`}
|
||||||
everything={scope === "all"}
|
everything={scope === "all"}
|
||||||
hint={(place) => learnedBudget(place.accountId)?.name ?? null}
|
hint={(place) => learnedBudgets.get(place.accountId)?.name ?? null}
|
||||||
ignoreKey="ffui.budget-ignored"
|
ignoreKey="ffui.budget-ignored"
|
||||||
loading={triage.isPending}
|
loading={triage.isPending}
|
||||||
noun="budget"
|
noun="budget"
|
||||||
onactivate={() => (triaging = true)}
|
onactivate={() => (triaging = true)}
|
||||||
onassign={remember}
|
onassign={(place, option) => learnedBudgets.remember(place.accountId, option)}
|
||||||
onchange={() => refreshExcept(triageKey)}
|
onchange={() => refreshExcept(triageKey)}
|
||||||
onleave={() => (triaging = false)}
|
onleave={() => (triaging = false)}
|
||||||
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Category, One } from "#lib/api/types.js";
|
import type { Category, One } from "#lib/api/types.js";
|
||||||
|
import { learnedCategories } from "#lib/budgets/map.js";
|
||||||
import {
|
import {
|
||||||
placesOf,
|
placesOf,
|
||||||
refreshExcept,
|
refreshExcept,
|
||||||
@@ -414,10 +415,12 @@
|
|||||||
? "Every transaction has a category."
|
? "Every transaction has a category."
|
||||||
: `Every transaction in ${period.label} has a category.`}
|
: `Every transaction in ${period.label} has a category.`}
|
||||||
{everything}
|
{everything}
|
||||||
|
hint={(place) => learnedCategories.get(place.key)?.name ?? null}
|
||||||
ignoreKey="ffui.category-ignored"
|
ignoreKey="ffui.category-ignored"
|
||||||
loading={triage.isPending}
|
loading={triage.isPending}
|
||||||
noun="category"
|
noun="category"
|
||||||
onactivate={() => (triaging = true)}
|
onactivate={() => (triaging = true)}
|
||||||
|
onassign={(place, option) => learnedCategories.remember(place.key, option)}
|
||||||
onchange={() => refreshExcept(triageKey)}
|
onchange={() => refreshExcept(triageKey)}
|
||||||
onleave={() => (triaging = false)}
|
onleave={() => (triaging = false)}
|
||||||
onswitch={() => setParam("scope", everything ? "" : "all")}
|
onswitch={() => setParam("scope", everything ? "" : "all")}
|
||||||
|
|||||||
Reference in new issue
Block a user