From df0e19808fd8360f7702877f321c0d2aa1dd52f4 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 25 Oct 2026 23:20:48 +0000 Subject: [PATCH] feat(editor,categories): learned category per place, subscription filled from the matched description --- src/lib/budgets/map.ts | 51 +++++++++++------- src/lib/components/editor/SplitBox.svelte | 53 +++++++++++++------ src/lib/components/editor/catalog.svelte.ts | 52 +++++++++++++----- .../[[start=date]]/[[end=date]]/+page.svelte | 12 ++--- src/routes/categories/+page.svelte | 3 ++ 5 files changed, 111 insertions(+), 60 deletions(-) diff --git a/src/lib/budgets/map.ts b/src/lib/budgets/map.ts index 2c370e8..dd72692 100644 --- a/src/lib/budgets/map.ts +++ b/src/lib/budgets/map.ts @@ -1,28 +1,39 @@ -const KEY = "ffui.budget-map"; - export interface Learned { id: string; name: string; } -function read(): Record { - try { - const value = JSON.parse(localStorage.getItem(KEY) ?? "{}"); - return value && typeof value === "object" ? value : {}; - } catch { - return {}; - } +function learnedMap(key: string) { + const read = (): Record => { + try { + const value = JSON.parse(localStorage.getItem(key) ?? "{}"); + return value && typeof value === "object" ? value : {}; + } 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 => - read()[destinationId] ?? null; +export const learnedBudgets = learnedMap("ffui.budget-map"); -export function learnBudget(destinationId: string, budget: Learned | null) { - const map = read(); - if (budget) { - map[destinationId] = { id: budget.id, name: budget.name }; - } else { - delete map[destinationId]; - } - localStorage.setItem(KEY, JSON.stringify(map)); -} +export const learnedCategories = learnedMap("ffui.category-map"); diff --git a/src/lib/components/editor/SplitBox.svelte b/src/lib/components/editor/SplitBox.svelte index ef82621..53e4bf1 100644 --- a/src/lib/components/editor/SplitBox.svelte +++ b/src/lib/components/editor/SplitBox.svelte @@ -75,6 +75,26 @@ function fill(past: Split, withCounterparty: boolean) { const parts: string[] = []; 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; if (withCounterparty && side && past.type === form.type) { const pastId = past[`${side}_id`]; @@ -87,33 +107,32 @@ ) { const next = toAccountRef(account); split[side] = next; - parts.push(next.name); - undo.push(() => { - if (split[side].id === next.id) { + put( + next.name, + () => split[side].id === next.id, + () => { split[side] = emptyAccount(); } - }); + ); } } const category = past.category_name ?? ""; if (category && !split.category.trim()) { split.category = category; - parts.push(`category ${category}`); - undo.push(() => { - if (split.category === category) { + put( + `category ${category}`, + () => split.category === category, + () => { split.category = ""; } - }); + ); } - const budget = past.budget_id; - if (withdrawal && budget && !(split.budget.id || split.budget.name)) { - split.budget = { id: budget, name: past.budget_name ?? "" }; - parts.push(`budget ${past.budget_name ?? ""}`); - undo.push(() => { - if (split.budget.id === budget) { - split.budget = emptyRef(); - } - }); + if (past.budget_id) { + putRef("budget", past.budget_id, past.budget_name ?? ""); + } + const bill = withCounterparty ? catalog.billById(past.bill_id) : undefined; + if (bill) { + putRef("bill", bill.id, bill.attributes.name ?? ""); } if (parts.length) { filled = { diff --git a/src/lib/components/editor/catalog.svelte.ts b/src/lib/components/editor/catalog.svelte.ts index 292811a..ec8b744 100644 --- a/src/lib/components/editor/catalog.svelte.ts +++ b/src/lib/components/editor/catalog.svelte.ts @@ -8,7 +8,7 @@ import type { Page, TransactionGroup, } 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 { formatMoney } from "#lib/format.js"; import { fuzzyFilter } from "#lib/fuzzy.js"; @@ -66,6 +66,12 @@ export class Catalog { 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[] { const items = rank( this.accountsOf(kinds), @@ -235,23 +241,41 @@ export class Catalog { .catch(() => null); this.history.set(key, found); } - const learned = learnedBudget(accountId)?.id; - const budget = this.budgets.data?.find( - (item) => item.id === learned && item.attributes.active - ); - if (!budget || type !== "withdrawal" || side !== "destination") { + const learnedBudget = learnedBudgets.get(accountId)?.id; + const budget = + type === "withdrawal" && side === "destination" + ? this.budgets.data?.find( + (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.then( - (past) => - past && { - split: { - ...past.split, + return found.then((past) => { + const split = past?.split; + if (!split) { + return past; + } + return { + split: { + ...split, + ...(budget && { budget_id: budget.id, budget_name: budget.attributes.name, - }, - } - ); + }), + ...(category && { + category_id: category.id, + category_name: category.attributes.name, + }), + }, + }; + }); } async fromJournal(journalId: string): Promise { diff --git a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte index 5cdeb49..3d3dd68 100644 --- a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte +++ b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte @@ -4,7 +4,7 @@ import { api } from "#lib/api/client.js"; import { invalidate } from "#lib/api/lists.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 { type Place, placesOf, @@ -269,12 +269,6 @@ 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) { adding = null; const next = rows[index + move]; @@ -739,12 +733,12 @@ ? "Every expense has a budget." : `Every expense in ${period.label} has a budget.`} everything={scope === "all"} - hint={(place) => learnedBudget(place.accountId)?.name ?? null} + hint={(place) => learnedBudgets.get(place.accountId)?.name ?? null} ignoreKey="ffui.budget-ignored" loading={triage.isPending} noun="budget" onactivate={() => (triaging = true)} - onassign={remember} + onassign={(place, option) => learnedBudgets.remember(place.accountId, option)} onchange={() => refreshExcept(triageKey)} onleave={() => (triaging = false)} onswitch={() => setParam("scope", scope === "all" ? "" : "all")} diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte index c20c6b2..0dae95f 100644 --- a/src/routes/categories/+page.svelte +++ b/src/routes/categories/+page.svelte @@ -5,6 +5,7 @@ import { api } from "#lib/api/client.js"; import { invalidate } from "#lib/api/lists.js"; import type { Category, One } from "#lib/api/types.js"; + import { learnedCategories } from "#lib/budgets/map.js"; import { placesOf, refreshExcept, @@ -414,10 +415,12 @@ ? "Every transaction has a category." : `Every transaction in ${period.label} has a category.`} {everything} + hint={(place) => learnedCategories.get(place.key)?.name ?? null} ignoreKey="ffui.category-ignored" loading={triage.isPending} noun="category" onactivate={() => (triaging = true)} + onassign={(place, option) => learnedCategories.remember(place.key, option)} onchange={() => refreshExcept(triageKey)} onleave={() => (triaging = false)} onswitch={() => setParam("scope", everything ? "" : "all")}