feat(editor,categories): learned category per place, subscription filled from the matched description

This commit is contained in:
hh committed 2026-10-25 23:26:06 +00:00
1 parent 92db7d4093
commit df0e19808f
5 files changed
+111 -60

No files matched your search

+31 -20
View File
@@ -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));
}
+36 -17
View File
@@ -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 = {
+38 -14
View File
@@ -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")}
+3
View File
@@ -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")}