feat: tag clean-up converters, saved searches, quick entry subscriptions and foreign amounts
Tag clean-up lists tags named like a category, account or subscription, tags used once and unused ones; s c and s s move a tag's transactions to a category or subscription, Enter again deletes the tag. P pins a search, pins and built-ins show in the palette. Quick entry takes %subscription and =520 UAH. The transaction page compares the amount with the usual one at the same place.
This commit is contained in:
1 parent
6f39343e07
commit
01c53d982d
13 files changed
+1093
-102
No files matched your search
@@ -26,6 +26,7 @@
|
|||||||
tagsOf,
|
tagsOf,
|
||||||
} from "#lib/components/editor/quick.js";
|
} from "#lib/components/editor/quick.js";
|
||||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import { evalAmount } from "#lib/components/inputs/parse.js";
|
||||||
import { LIST } from "#lib/components/inputs/styles.js";
|
import { LIST } from "#lib/components/inputs/styles.js";
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney } from "#lib/format.js";
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
|
|
||||||
const SUGGESTIONS = 6;
|
const SUGGESTIONS = 6;
|
||||||
const HINT =
|
const HINT =
|
||||||
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
|
"12.50 lunch @payee ^account !category $budget %subscription #tag ~yesterday · + in front for a deposit · >account for a transfer · 12.50 USD =520 UAH for another currency";
|
||||||
|
|
||||||
const catalog = new Catalog();
|
const catalog = new Catalog();
|
||||||
let input = $state<HTMLInputElement | null>(null);
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"!": () => catalog.categoryOptions(query),
|
"!": () => catalog.categoryOptions(query),
|
||||||
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
|
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
|
||||||
"#": () => catalog.tagOptions(query, tagsOf(quick)),
|
"#": () => catalog.tagOptions(query, tagsOf(quick)),
|
||||||
|
"%": () => catalog.billOptions(query),
|
||||||
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
|
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
|
||||||
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
|
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
|
||||||
"~": () => [],
|
"~": () => [],
|
||||||
@@ -107,12 +109,18 @@
|
|||||||
|
|
||||||
function money(form: Form) {
|
function money(form: Form) {
|
||||||
const [split] = form.splits;
|
const [split] = form.splits;
|
||||||
return formatMoney({
|
const main = formatMoney({
|
||||||
amount: quick.amount ?? 0,
|
...split.currency,
|
||||||
code: split.currency?.code,
|
amount: evalAmount(split.amount) ?? 0,
|
||||||
decimals: split.currency?.decimals,
|
|
||||||
symbol: split.currency?.symbol,
|
|
||||||
});
|
});
|
||||||
|
if (!split.foreignCurrency) {
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
const foreign = formatMoney({
|
||||||
|
...split.foreignCurrency,
|
||||||
|
amount: evalAmount(split.foreignAmount) ?? 0,
|
||||||
|
});
|
||||||
|
return `${main} (${foreign})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCaret() {
|
function syncCaret() {
|
||||||
@@ -252,6 +260,9 @@
|
|||||||
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
|
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (split.bill.id) {
|
||||||
|
parts.push({ text: `subscription ${split.bill.name}` });
|
||||||
|
}
|
||||||
if (split.budget.id) {
|
if (split.budget.id) {
|
||||||
parts.push({
|
parts.push({
|
||||||
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
|
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
|
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
import type { Catalog } from "./catalog.svelte.js";
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
import {
|
import {
|
||||||
|
type AccountRef,
|
||||||
|
type Cur,
|
||||||
emptyForm,
|
emptyForm,
|
||||||
KINDS,
|
KINDS,
|
||||||
OWN_SIDE,
|
OWN_SIDE,
|
||||||
@@ -13,9 +16,9 @@ import {
|
|||||||
toAccountRef,
|
toAccountRef,
|
||||||
} from "./model.js";
|
} from "./model.js";
|
||||||
|
|
||||||
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
|
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
|
||||||
|
|
||||||
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
|
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
|
||||||
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
||||||
const WORD = /\S+/g;
|
const WORD = /\S+/g;
|
||||||
const NUMERIC = /^[\d(]/;
|
const NUMERIC = /^[\d(]/;
|
||||||
@@ -27,21 +30,109 @@ export interface Segment {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Equal {
|
||||||
|
code: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Quick {
|
export interface Quick {
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
amountText: string;
|
amountText: string;
|
||||||
|
currency: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
equal: Equal | null;
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
type: TxType;
|
type: TxType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseQuick(input: string): Quick {
|
const enabledCodes = () =>
|
||||||
const segments: Segment[] = [];
|
new Set(
|
||||||
const words: string[] = [];
|
session.currencies
|
||||||
let amount: number | null = null;
|
.filter((item) => item.attributes.enabled)
|
||||||
let amountText = "";
|
.map((item) => item.attributes.code)
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Parse {
|
||||||
|
amount: number | null;
|
||||||
|
amountText: string;
|
||||||
|
coded: "amount" | "equal" | null;
|
||||||
|
currency: string;
|
||||||
|
current: Segment | null;
|
||||||
|
equal: Equal | null;
|
||||||
|
segments: Segment[];
|
||||||
|
words: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeCode(state: Parse, word: string, codes: Set<string>) {
|
||||||
|
const { coded } = state;
|
||||||
|
const code = word.toUpperCase();
|
||||||
|
state.coded = null;
|
||||||
|
if (!(coded && codes.has(code))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (coded === "amount") {
|
||||||
|
state.currency = code;
|
||||||
|
} else if (state.equal) {
|
||||||
|
state.equal.code = code;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeEqual(state: Parse, word: string) {
|
||||||
|
if (!word.startsWith("=") || state.equal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.equal = { code: "", text: word.slice(1) };
|
||||||
|
state.coded = "equal";
|
||||||
|
state.current = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeMarker(state: Parse, word: string, start: number) {
|
||||||
|
const { current } = state;
|
||||||
|
if (MARKERS.has(word[0])) {
|
||||||
|
const segment = {
|
||||||
|
end: start + word.length,
|
||||||
|
marker: word[0] as Marker,
|
||||||
|
start,
|
||||||
|
value: word.slice(1),
|
||||||
|
};
|
||||||
|
state.segments.push(segment);
|
||||||
|
state.current = MULTI.has(segment.marker) ? segment : null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
current.value = `${current.value} ${word}`;
|
||||||
|
current.end = start + word.length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeAmount(state: Parse, word: string) {
|
||||||
|
const value =
|
||||||
|
state.amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
|
||||||
|
if (value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.amount = value;
|
||||||
|
state.amountText = word;
|
||||||
|
state.coded = "amount";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuick(input: string, codes = enabledCodes()): Quick {
|
||||||
|
const state: Parse = {
|
||||||
|
amount: null,
|
||||||
|
amountText: "",
|
||||||
|
coded: null,
|
||||||
|
currency: "",
|
||||||
|
current: null,
|
||||||
|
equal: null,
|
||||||
|
segments: [],
|
||||||
|
words: [],
|
||||||
|
};
|
||||||
let deposit = false;
|
let deposit = false;
|
||||||
let current: Segment | null = null;
|
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const match of input.matchAll(WORD)) {
|
for (const match of input.matchAll(WORD)) {
|
||||||
let [word] = match;
|
let [word] = match;
|
||||||
@@ -52,42 +143,32 @@ export function parseQuick(input: string): Quick {
|
|||||||
start += 1;
|
start += 1;
|
||||||
}
|
}
|
||||||
first = false;
|
first = false;
|
||||||
if (!word) {
|
const taken =
|
||||||
continue;
|
!word ||
|
||||||
|
takeCode(state, word, codes) ||
|
||||||
|
takeEqual(state, word) ||
|
||||||
|
takeMarker(state, word, start) ||
|
||||||
|
takeAmount(state, word);
|
||||||
|
if (!taken) {
|
||||||
|
state.words.push(word);
|
||||||
}
|
}
|
||||||
if (MARKERS.has(word[0])) {
|
|
||||||
const segment = {
|
|
||||||
end: start + word.length,
|
|
||||||
marker: word[0] as Marker,
|
|
||||||
start,
|
|
||||||
value: word.slice(1),
|
|
||||||
};
|
|
||||||
segments.push(segment);
|
|
||||||
current = MULTI.has(segment.marker) ? segment : null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (current) {
|
|
||||||
current.value = `${current.value} ${word}`;
|
|
||||||
current.end = start + word.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value: number | null =
|
|
||||||
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
|
|
||||||
if (value !== null) {
|
|
||||||
amount = value;
|
|
||||||
amountText = word;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
words.push(word);
|
|
||||||
}
|
}
|
||||||
const hasTarget = segments.some((segment) => segment.marker === ">");
|
const hasTarget = state.segments.some((segment) => segment.marker === ">");
|
||||||
let type: TxType = "withdrawal";
|
let type: TxType = "withdrawal";
|
||||||
if (hasTarget) {
|
if (hasTarget) {
|
||||||
type = "transfer";
|
type = "transfer";
|
||||||
} else if (deposit) {
|
} else if (deposit) {
|
||||||
type = "deposit";
|
type = "deposit";
|
||||||
}
|
}
|
||||||
return { amount, amountText, description: words.join(" "), segments, type };
|
return {
|
||||||
|
amount: state.amount,
|
||||||
|
amountText: state.amountText,
|
||||||
|
currency: state.currency,
|
||||||
|
description: state.words.join(" "),
|
||||||
|
equal: state.equal,
|
||||||
|
segments: state.segments,
|
||||||
|
type,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markerValue = (quick: Quick, marker: Marker) =>
|
export const markerValue = (quick: Quick, marker: Marker) =>
|
||||||
@@ -158,6 +239,77 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
|
|||||||
: [`No asset account or liability called "${ownName}"`];
|
: [`No asset account or liability called "${ownName}"`];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function curOf(code: string | undefined): Cur | null {
|
||||||
|
const found = session.currencies.find(
|
||||||
|
(item) => item.attributes.code === code
|
||||||
|
);
|
||||||
|
return found
|
||||||
|
? {
|
||||||
|
code: found.attributes.code,
|
||||||
|
decimals: found.attributes.decimal_places ?? 2,
|
||||||
|
symbol: found.attributes.symbol,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMoney(
|
||||||
|
quick: Quick,
|
||||||
|
split: SplitForm,
|
||||||
|
own: AccountRef,
|
||||||
|
target: AccountRef | null
|
||||||
|
): string[] {
|
||||||
|
const native = own.currency?.code;
|
||||||
|
const { equal } = quick;
|
||||||
|
split.currency = own.currency;
|
||||||
|
split.amount = quick.amountText;
|
||||||
|
const first = quick.currency || native;
|
||||||
|
if (!native) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!equal) {
|
||||||
|
return first === native
|
||||||
|
? []
|
||||||
|
: [`${own.name} is in ${native}: add =amount ${native}`];
|
||||||
|
}
|
||||||
|
const second =
|
||||||
|
equal.code || (first === native ? target?.currency?.code : native);
|
||||||
|
if (first === native && second && second !== native) {
|
||||||
|
split.foreignAmount = equal.text;
|
||||||
|
split.foreignCurrency = curOf(second);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (second === native && first !== native) {
|
||||||
|
split.amount = equal.text;
|
||||||
|
split.foreignAmount = quick.amountText;
|
||||||
|
split.foreignCurrency = curOf(first);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const example =
|
||||||
|
session.currencies.find(
|
||||||
|
(item) => item.attributes.enabled && item.attributes.code !== native
|
||||||
|
)?.attributes.code ?? "USD";
|
||||||
|
return [`Name the other currency, like =${equal.text || "520"} ${example}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBill(quick: Quick, catalog: Catalog, split: SplitForm) {
|
||||||
|
const name = markerValue(quick, "%");
|
||||||
|
if (!name) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (quick.type !== "withdrawal") {
|
||||||
|
return ["Only a withdrawal can pay a subscription"];
|
||||||
|
}
|
||||||
|
const wanted = name.toLowerCase();
|
||||||
|
const bill = (catalog.bills.data ?? []).find(
|
||||||
|
(item) => item.attributes.name?.toLowerCase() === wanted
|
||||||
|
);
|
||||||
|
if (!bill) {
|
||||||
|
return [`No subscription called "${name}"`];
|
||||||
|
}
|
||||||
|
split.bill = { id: bill.id, name: bill.attributes.name ?? name };
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLinks(
|
function resolveLinks(
|
||||||
quick: Quick,
|
quick: Quick,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
@@ -199,10 +351,17 @@ export function resolveQuick(
|
|||||||
split,
|
split,
|
||||||
counterparty ? recent[counterparty] : null
|
counterparty ? recent[counterparty] : null
|
||||||
);
|
);
|
||||||
problems.push(...links.problems);
|
problems.push(
|
||||||
|
...links.problems,
|
||||||
|
...resolveBill(quick, catalog, split),
|
||||||
|
...resolveMoney(
|
||||||
|
quick,
|
||||||
|
split,
|
||||||
|
split[own],
|
||||||
|
quick.type === "transfer" ? split[other] : null
|
||||||
|
)
|
||||||
|
);
|
||||||
split.tags = tagsOf(quick);
|
split.tags = tagsOf(quick);
|
||||||
split.amount = quick.amountText;
|
|
||||||
split.currency = split[own].currency;
|
|
||||||
split.description =
|
split.description =
|
||||||
quick.description ||
|
quick.description ||
|
||||||
split[other].name ||
|
split[other].name ||
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
import { keys, prettyKeys } from "#lib/keys.svelte.js";
|
||||||
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
|
||||||
|
import { searches } from "#lib/transactions/searches.svelte.js";
|
||||||
import { ui } from "#lib/ui.svelte.js";
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
@@ -214,8 +215,21 @@
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const searchEntries = $derived<Entry[]>(
|
||||||
|
ui.palette
|
||||||
|
? searches.list().map((saved) => ({
|
||||||
|
group: "Searches",
|
||||||
|
hint: saved.pinned ? "pinned" : undefined,
|
||||||
|
id: `saved:${saved.query}`,
|
||||||
|
label: saved.label,
|
||||||
|
run: () => searches.open(saved),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
const entries = $derived([
|
const entries = $derived([
|
||||||
...pageEntries,
|
...pageEntries,
|
||||||
|
...searchEntries,
|
||||||
...staticEntries,
|
...staticEntries,
|
||||||
...commandEntries,
|
...commandEntries,
|
||||||
...entityEntries,
|
...entityEntries,
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Item, Split, Tag } from "#lib/api/types.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import { convertTag, deleteTag } from "./actions.js";
|
||||||
|
|
||||||
|
export interface Choice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = 6;
|
||||||
|
const NOUN = { bill: "subscription", category: "category" } as const;
|
||||||
|
|
||||||
|
let {
|
||||||
|
tag,
|
||||||
|
kind,
|
||||||
|
choices,
|
||||||
|
initial,
|
||||||
|
ondone,
|
||||||
|
}: {
|
||||||
|
tag: Item<Tag>;
|
||||||
|
kind: "bill" | "category";
|
||||||
|
choices: Choice[];
|
||||||
|
initial: string;
|
||||||
|
ondone: (status?: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let value = $state(untrack(() => initial));
|
||||||
|
let active = $state(-1);
|
||||||
|
let busy = $state("");
|
||||||
|
let fresh = $state("");
|
||||||
|
let done = $state("");
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
const lower = (text: string | null | undefined) =>
|
||||||
|
(text ?? "").trim().toLowerCase();
|
||||||
|
const exact = $derived(
|
||||||
|
choices.find((choice) => lower(choice.name) === lower(value))
|
||||||
|
);
|
||||||
|
const options = $derived(
|
||||||
|
done || busy || exact
|
||||||
|
? []
|
||||||
|
: fuzzyFilter(choices, value, (choice) => choice.name, SUGGESTIONS)
|
||||||
|
);
|
||||||
|
|
||||||
|
function focus(input: HTMLInputElement) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed(failure: unknown) {
|
||||||
|
problem = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(name: string, splits: Split[]) {
|
||||||
|
if (kind === "bill") {
|
||||||
|
const others = splits.filter((s) => s.type !== "withdrawal").length;
|
||||||
|
const linked = `${splits.length - others} linked to ${name}`;
|
||||||
|
return others
|
||||||
|
? `${linked}, ${others} not withdrawals keep only the tag`
|
||||||
|
: linked;
|
||||||
|
}
|
||||||
|
const before = [
|
||||||
|
...new Set(
|
||||||
|
splits
|
||||||
|
.map((s) => s.category_name ?? "")
|
||||||
|
.filter((c) => c && lower(c) !== lower(name))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const moved = `${splits.length} in ${name} now`;
|
||||||
|
return before.length ? `${moved}, before: ${before.join(", ")}` : moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFor(choice: Choice | undefined, name: string) {
|
||||||
|
if (kind === "bill") {
|
||||||
|
return (split: Split) =>
|
||||||
|
split.type === "withdrawal" && split.bill_id !== choice?.id
|
||||||
|
? { bill_id: choice?.id }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return (split: Split) =>
|
||||||
|
lower(split.category_name) === lower(name)
|
||||||
|
? null
|
||||||
|
: { category_name: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(choice: Choice | undefined) {
|
||||||
|
const name = choice?.name ?? value.trim();
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
problem = `Type a ${NOUN[kind]}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "bill" && !choice) {
|
||||||
|
problem = `No subscription called "${name}"`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!choice && fresh !== name) {
|
||||||
|
fresh = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = "Finding transactions";
|
||||||
|
problem = "";
|
||||||
|
try {
|
||||||
|
const { outcome, splits } = await convertTag(
|
||||||
|
tag,
|
||||||
|
patchFor(choice, name),
|
||||||
|
(o) => (busy = `Moving ${o.done} of ${o.total}`)
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
if (outcome.errors.length > 0) {
|
||||||
|
problem = `${outcome.errors.length} failed: ${outcome.errors[0]}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done = summary(name, splits);
|
||||||
|
} catch (failure) {
|
||||||
|
failed(failure);
|
||||||
|
} finally {
|
||||||
|
busy = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
busy = "Deleting the tag";
|
||||||
|
try {
|
||||||
|
await deleteTag(tag);
|
||||||
|
await invalidate();
|
||||||
|
ondone();
|
||||||
|
} catch (failure) {
|
||||||
|
failed(failure);
|
||||||
|
busy = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(down: boolean) {
|
||||||
|
const count = options.length;
|
||||||
|
active =
|
||||||
|
(Math.max(active, down ? -1 : 0) + (down ? 1 : -1) + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
const arrow = event.key === "ArrowDown" || event.key === "ArrowUp";
|
||||||
|
if (arrow && options.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
step(event.key === "ArrowDown");
|
||||||
|
} else if (event.key === "Tab" && options.length > 0 && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
value = options[Math.max(active, 0)].name;
|
||||||
|
active = -1;
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (done) {
|
||||||
|
remove();
|
||||||
|
} else {
|
||||||
|
run(active >= 0 ? options[active] : exact);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
ondone(done || undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 py-0.5">
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">To {NOUN[kind]}</span>
|
||||||
|
<input
|
||||||
|
aria-label="Move {tag.attributes.tag} to a {NOUN[kind]}"
|
||||||
|
class="cladd-cut cladd-focusable-input h-6 w-56 rounded-cladd-sm px-1.5 text-[13px]"
|
||||||
|
oninput={() => {
|
||||||
|
active = -1;
|
||||||
|
fresh = "";
|
||||||
|
problem = "";
|
||||||
|
}}
|
||||||
|
{onkeydown}
|
||||||
|
readonly={Boolean(busy || done)}
|
||||||
|
bind:value
|
||||||
|
{@attach focus}
|
||||||
|
>
|
||||||
|
{#if options.length > 0}
|
||||||
|
<span
|
||||||
|
aria-label="Suggestions"
|
||||||
|
class="flex flex-wrap items-baseline gap-x-2.5 text-[12px]"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
<span class="kbd">Tab</span>
|
||||||
|
{#each options as option, i (option.id)}
|
||||||
|
<span
|
||||||
|
aria-selected={i === Math.max(active, 0)}
|
||||||
|
class="cursor-pointer {i === Math.max(active, 0)
|
||||||
|
? "font-semibold text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
run(option);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft" role="status">
|
||||||
|
{#if busy}
|
||||||
|
{busy}
|
||||||
|
{:else if done}
|
||||||
|
<span class="text-cladd-fg">{done}.</span>
|
||||||
|
<span class="kbd">↵</span>
|
||||||
|
delete the tag <span class="kbd">Esc</span> keep it
|
||||||
|
{:else if fresh}
|
||||||
|
No {NOUN[kind]} called "{fresh}" yet. <span class="kbd">↵</span>
|
||||||
|
again creates it
|
||||||
|
{:else}
|
||||||
|
<span class="kbd">↵</span>
|
||||||
|
move its transactions <span class="kbd">Esc</span> cancel
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if problem}
|
||||||
|
<span class="basis-full text-[12px] text-expense">{problem}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -71,18 +71,42 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex flex-wrap items-center gap-2 py-0.5">
|
<div class="flex flex-wrap items-center gap-2 py-0.5">
|
||||||
<span class="text-[12px] text-cladd-fg-soft">Merge into</span>
|
<span class="text-[12px] text-cladd-fg-soft">Merge into</span>
|
||||||
<input
|
<input
|
||||||
aria-label="Merge {tag.attributes.tag} into"
|
aria-label="Merge {tag.attributes.tag} into"
|
||||||
class="cladd-cut cladd-focusable-input h-6 w-60 rounded-cladd-sm px-1.5 text-[13px]"
|
class="cladd-cut cladd-focusable-input h-6 w-60 rounded-cladd-sm px-1.5 text-[13px]"
|
||||||
disabled={busy}
|
|
||||||
oninput={() => (cursor = 0)}
|
oninput={() => (cursor = 0)}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
placeholder="tag name"
|
placeholder="tag name"
|
||||||
|
readonly={busy}
|
||||||
bind:value
|
bind:value
|
||||||
{@attach focus}
|
{@attach focus}
|
||||||
>
|
>
|
||||||
|
{#if !busy && options.length > 0}
|
||||||
|
<span
|
||||||
|
aria-label="Suggestions"
|
||||||
|
class="flex flex-wrap items-baseline gap-x-2.5 text-[12px]"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{#each options as option, i (option.id)}
|
||||||
|
<span
|
||||||
|
aria-selected={i === cursor}
|
||||||
|
class="cursor-pointer {i === cursor
|
||||||
|
? "font-semibold text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
run(option.attributes.tag);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{option.attributes.tag}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<span class="text-[12px] text-cladd-fg-soft">
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
{#if busy && outcome}
|
{#if busy && outcome}
|
||||||
Moving {outcome.done} of {outcome.total} transactions
|
Moving {outcome.done} of {outcome.total} transactions
|
||||||
@@ -96,28 +120,4 @@
|
|||||||
{#if problem}
|
{#if problem}
|
||||||
<span class="basis-full text-[12px] text-expense">{problem}</span>
|
<span class="basis-full text-[12px] text-expense">{problem}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !busy && options.length > 0}
|
|
||||||
<div
|
|
||||||
class="cladd-surface cladd-outline cladd-level-1 absolute top-full left-[4.6rem] z-20 mt-1 w-60 rounded-cladd-md p-1 shadow-cladd-popover"
|
|
||||||
role="listbox"
|
|
||||||
>
|
|
||||||
{#each options as option, i (option.id)}
|
|
||||||
<div
|
|
||||||
aria-selected={i === cursor}
|
|
||||||
class="cursor-default truncate rounded-cladd-sm px-2 py-1 text-[13px] {i ===
|
|
||||||
cursor
|
|
||||||
? "bg-row-cursor"
|
|
||||||
: "hover:bg-row-hover"}"
|
|
||||||
onmousedown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
run(option.attributes.tag);
|
|
||||||
}}
|
|
||||||
role="option"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
{option.attributes.tag}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all } from "#lib/api/lists.js";
|
import { all } from "#lib/api/lists.js";
|
||||||
import type { Item, Page, Tag, TransactionGroup } from "#lib/api/types.js";
|
import type {
|
||||||
|
Item,
|
||||||
|
Page,
|
||||||
|
Split,
|
||||||
|
Tag,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
import {
|
import {
|
||||||
type Outcome,
|
type Outcome,
|
||||||
|
type Patch,
|
||||||
type Targets,
|
type Targets,
|
||||||
updateGroups,
|
updateGroups,
|
||||||
} from "#lib/transactions/mass.js";
|
} from "#lib/transactions/mass.js";
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
counts: Map<string, number>;
|
||||||
|
samples: Map<string, Item<TransactionGroup>>;
|
||||||
|
}
|
||||||
|
|
||||||
const lower = (tag: Item<Tag>) => tag.attributes.tag.toLowerCase();
|
const lower = (tag: Item<Tag>) => tag.attributes.tag.toLowerCase();
|
||||||
|
|
||||||
const tagged = (groups: Item<TransactionGroup>[], name: string) =>
|
const tagged = (groups: Item<TransactionGroup>[], name: string) =>
|
||||||
@@ -61,7 +73,31 @@ export async function mergeTag(
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
|
export async function convertTag(
|
||||||
|
tag: Item<Tag>,
|
||||||
|
patch: (split: Split) => Patch | null,
|
||||||
|
progress: (outcome: Outcome) => void
|
||||||
|
): Promise<{ outcome: Outcome; splits: Split[] }> {
|
||||||
|
const name = tag.attributes.tag;
|
||||||
|
const groups = tagged(
|
||||||
|
await all<TransactionGroup>(`/v1/tags/${tag.id}/transactions`, {
|
||||||
|
type: "all",
|
||||||
|
}),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
const has = (split: Split) => split.tags?.includes(name) ?? false;
|
||||||
|
const outcome = await updateGroups(
|
||||||
|
new Map(groups.map((group) => [group.id, null])),
|
||||||
|
(split) => (has(split) ? patch(split) : null),
|
||||||
|
progress
|
||||||
|
);
|
||||||
|
const splits = groups.flatMap((group) =>
|
||||||
|
group.attributes.transactions.filter(has)
|
||||||
|
);
|
||||||
|
return { outcome, splits };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usage(tags: Item<Tag>[]): Promise<Usage> {
|
||||||
const keys = tags.map(lower);
|
const keys = tags.map(lower);
|
||||||
const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i));
|
const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i));
|
||||||
const plain = tags.filter((tag) => !clash.has(lower(tag)));
|
const plain = tags.filter((tag) => !clash.has(lower(tag)));
|
||||||
@@ -75,6 +111,11 @@ export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
|
|||||||
const counts = new Map(
|
const counts = new Map(
|
||||||
plain.map((tag, i) => [tag.id, pages[i].meta.pagination?.total ?? 0])
|
plain.map((tag, i) => [tag.id, pages[i].meta.pagination?.total ?? 0])
|
||||||
);
|
);
|
||||||
|
const samples = new Map(
|
||||||
|
plain.flatMap((tag, i) =>
|
||||||
|
pages[i].data[0] ? [[tag.id, pages[i].data[0]] as const] : []
|
||||||
|
)
|
||||||
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...clash].map(async (key) => {
|
[...clash].map(async (key) => {
|
||||||
const variants = tags.filter((tag) => lower(tag) === key);
|
const variants = tags.filter((tag) => lower(tag) === key);
|
||||||
@@ -83,11 +124,15 @@ export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
|
|||||||
{ type: "all" }
|
{ type: "all" }
|
||||||
);
|
);
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
counts.set(variant.id, tagged(groups, variant.attributes.tag).length);
|
const own = tagged(groups, variant.attributes.tag);
|
||||||
|
counts.set(variant.id, own.length);
|
||||||
|
if (own[0]) {
|
||||||
|
samples.set(variant.id, own[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return counts;
|
return { counts, samples };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sequence<T>(items: T[], run: (item: T) => Promise<void>) {
|
export function sequence<T>(items: T[], run: (item: T) => Promise<void>) {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Bill,
|
||||||
|
Category,
|
||||||
|
Item,
|
||||||
|
Tag,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import type { Usage } from "./actions.js";
|
||||||
|
import { squash } from "./tree.js";
|
||||||
|
|
||||||
|
export type Section = "same" | "once" | "unused" | "rest";
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
account?: Item<Account>;
|
||||||
|
bill?: Item<Bill>;
|
||||||
|
category?: Item<Category>;
|
||||||
|
sample?: Item<TransactionGroup>;
|
||||||
|
section: Section;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECTIONS: Record<Section, string> = {
|
||||||
|
once: "Used once",
|
||||||
|
rest: "Other tags",
|
||||||
|
same: "Same name as a category, account or subscription",
|
||||||
|
unused: "Not used",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER: Section[] = ["same", "once", "unused", "rest"];
|
||||||
|
const MIN_PREFIX = 3;
|
||||||
|
|
||||||
|
export const leafOf = (tag: string) => tag.slice(tag.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
export const rankOf = (finding: Finding | undefined) =>
|
||||||
|
ORDER.indexOf(finding?.section ?? "rest");
|
||||||
|
|
||||||
|
function sectionOf(finding: Omit<Finding, "section">, count?: number) {
|
||||||
|
if (finding.account || finding.bill || finding.category) {
|
||||||
|
return "same";
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return "once";
|
||||||
|
}
|
||||||
|
return count === 0 ? "unused" : "rest";
|
||||||
|
}
|
||||||
|
|
||||||
|
function index<T>(items: Item<T>[], name: (item: T) => string) {
|
||||||
|
return new Map(items.map((item) => [squash(name(item.attributes)), item]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findings(
|
||||||
|
tags: Item<Tag>[],
|
||||||
|
lists: {
|
||||||
|
accounts: Item<Account>[];
|
||||||
|
bills: Item<Bill>[];
|
||||||
|
categories: Item<Category>[];
|
||||||
|
usage?: Usage;
|
||||||
|
}
|
||||||
|
): Map<string, Finding> {
|
||||||
|
const accounts = index(lists.accounts, (a) => a.name);
|
||||||
|
const categories = index(lists.categories, (c) => c.name);
|
||||||
|
return new Map(
|
||||||
|
tags.map((tag) => {
|
||||||
|
const leaf = squash(leafOf(tag.attributes.tag));
|
||||||
|
const count = lists.usage?.counts.get(tag.id);
|
||||||
|
const found = {
|
||||||
|
account: accounts.get(leaf),
|
||||||
|
bill:
|
||||||
|
leaf.length < MIN_PREFIX
|
||||||
|
? undefined
|
||||||
|
: lists.bills.find((bill) =>
|
||||||
|
squash(bill.attributes.name ?? "").startsWith(leaf)
|
||||||
|
),
|
||||||
|
category: categories.get(leaf),
|
||||||
|
sample: count === 1 ? lists.usage?.samples.get(tag.id) : undefined,
|
||||||
|
};
|
||||||
|
return [tag.id, { ...found, section: sectionOf(found, count) }];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ export function tagStats(items: Row[], rollup: boolean): Map<string, Stats> {
|
|||||||
const normal = (value: string) =>
|
const normal = (value: string) =>
|
||||||
value.normalize("NFKD").replace(DIACRITICS, "").toLowerCase();
|
value.normalize("NFKD").replace(DIACRITICS, "").toLowerCase();
|
||||||
|
|
||||||
const squash = (value: string) => normal(value).replace(NOT_WORD, "");
|
export const squash = (value: string) => normal(value).replace(NOT_WORD, "");
|
||||||
|
|
||||||
function distance(a: string, b: string): number {
|
function distance(a: string, b: string): number {
|
||||||
const rows = a.length + 1;
|
const rows = a.length + 1;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { addDays, periodOf, today } from "#lib/dates.js";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const KEY = "ffui.searches";
|
||||||
|
const SEEN_KEY = "ffui.searches.seen";
|
||||||
|
const FIRST_LOOK_DAYS = 7;
|
||||||
|
|
||||||
|
export interface SavedSearch {
|
||||||
|
label: string;
|
||||||
|
pinned: boolean;
|
||||||
|
query: string;
|
||||||
|
since?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): string[] {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((item) => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = () =>
|
||||||
|
localStorage.getItem(SEEN_KEY) ?? addDays(today(), -FIRST_LOOK_DAYS);
|
||||||
|
|
||||||
|
class Searches {
|
||||||
|
pinned = $state<string[]>(typeof localStorage === "undefined" ? [] : load());
|
||||||
|
|
||||||
|
has(query: string) {
|
||||||
|
return this.pinned.includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(query: string): boolean {
|
||||||
|
const pin = !this.has(query);
|
||||||
|
this.pinned = pin
|
||||||
|
? [...this.pinned, query]
|
||||||
|
: this.pinned.filter((item) => item !== query);
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(this.pinned));
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): SavedSearch[] {
|
||||||
|
const month = periodOf(today(), "1M");
|
||||||
|
const range = `date_after:${month.start} date_before:${month.end}`;
|
||||||
|
const builtins = [
|
||||||
|
{
|
||||||
|
label: `Since I last looked, ${formatDate(seen(), "short")}`,
|
||||||
|
query: `created_at_after:${seen()}`,
|
||||||
|
since: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "No budget this month",
|
||||||
|
query: `type:withdrawal has_no_budget:true ${range}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "No category this month",
|
||||||
|
query: `has_no_category:true ${range}`,
|
||||||
|
},
|
||||||
|
{ label: "Has notes", query: "has_notes:true" },
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
...builtins.map((item) => ({ ...item, pinned: false })),
|
||||||
|
...this.pinned.map((query) => ({ label: query, pinned: true, query })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
open(saved: SavedSearch) {
|
||||||
|
if (saved.since) {
|
||||||
|
localStorage.setItem(SEEN_KEY, today());
|
||||||
|
}
|
||||||
|
return goto(`/search?search=${encodeURIComponent(saved.query)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searches = new Searches();
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { explain, operatorsQuery } from "#lib/transactions/query.js";
|
import { explain, operatorsQuery } from "#lib/transactions/query.js";
|
||||||
|
import { searches } from "#lib/transactions/searches.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
|
|
||||||
let input = $state<HTMLInputElement>();
|
let input = $state<HTMLInputElement>();
|
||||||
let seconds = $state(0);
|
let seconds = $state(0);
|
||||||
|
let pinNote = $state({ query: "", text: "" });
|
||||||
|
|
||||||
const query = $derived(page.url.searchParams.get("search")?.trim() ?? "");
|
const query = $derived(page.url.searchParams.get("search")?.trim() ?? "");
|
||||||
const pageNo = $derived(
|
const pageNo = $derived(
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const pagination = $derived(results.data?.meta.pagination);
|
const pagination = $derived(results.data?.meta.pagination);
|
||||||
|
const pinned = $derived(searches.has(query));
|
||||||
const explanation = $derived(explain(query, operators.data ?? []));
|
const explanation = $derived(explain(query, operators.data ?? []));
|
||||||
|
|
||||||
function submit(text: string) {
|
function submit(text: string) {
|
||||||
@@ -83,6 +86,18 @@
|
|||||||
focus();
|
focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePin() {
|
||||||
|
if (!query) {
|
||||||
|
focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pin = searches.toggle(query);
|
||||||
|
pinNote = {
|
||||||
|
query,
|
||||||
|
text: pin ? "Pinned, it is in the palette under Searches" : "Unpinned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (input && query === "") {
|
if (input && query === "") {
|
||||||
input.focus();
|
input.focus();
|
||||||
@@ -91,6 +106,12 @@
|
|||||||
|
|
||||||
useKeys(() => [
|
useKeys(() => [
|
||||||
{ group: "Global", keys: "/", label: "Focus search", run: focus },
|
{ group: "Global", keys: "/", label: "Focus search", run: focus },
|
||||||
|
{
|
||||||
|
group: "Search",
|
||||||
|
keys: "P",
|
||||||
|
label: pinned ? "Unpin this search" : "Pin this search",
|
||||||
|
run: togglePin,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -118,6 +139,14 @@
|
|||||||
/>
|
/>
|
||||||
<Button size="sm" type="submit"><Search />Search</Button>
|
<Button size="sm" type="submit"><Search />Search</Button>
|
||||||
{#if query}
|
{#if query}
|
||||||
|
<Button onclick={togglePin} size="sm" variant="outline">
|
||||||
|
{pinned ? "Unpin" : "Pin"} <span class="kbd max-sm:hidden">P</span>
|
||||||
|
</Button>
|
||||||
|
{#if pinNote.query === query && pinNote.text}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft" role="status"
|
||||||
|
>{pinNote.text}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
<a
|
<a
|
||||||
class="text-[12px] text-brand hover:underline"
|
class="text-[12px] text-brand hover:underline"
|
||||||
data-sveltekit-reload
|
data-sveltekit-reload
|
||||||
@@ -144,6 +173,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
<span>Tab or Enter accepts a suggestion.</span>
|
<span>Tab or Enter accepts a suggestion.</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span class="text-cladd-fg-soft">Saved:</span>
|
||||||
|
{#each searches.list() as saved (saved.query)}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href="/search?search={encodeURIComponent(saved.query)}"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
searches.open(saved);
|
||||||
|
}}
|
||||||
|
>{saved.label}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
+224
-26
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||||
import type { TransactionGroup } from "#lib/api/types.js";
|
import type { Item, Tag, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||||
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
@@ -12,7 +13,15 @@
|
|||||||
import { setParam } from "#lib/components/categories/url.js";
|
import { setParam } from "#lib/components/categories/url.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { deleteTag, usage } from "#lib/components/tags/actions.js";
|
import { deleteTag, usage } from "#lib/components/tags/actions.js";
|
||||||
|
import ConvertField from "#lib/components/tags/ConvertField.svelte";
|
||||||
import Duplicates from "#lib/components/tags/Duplicates.svelte";
|
import Duplicates from "#lib/components/tags/Duplicates.svelte";
|
||||||
|
import {
|
||||||
|
type Finding,
|
||||||
|
findings,
|
||||||
|
leafOf,
|
||||||
|
rankOf,
|
||||||
|
SECTIONS,
|
||||||
|
} from "#lib/components/tags/findings.js";
|
||||||
import MergeField from "#lib/components/tags/MergeField.svelte";
|
import MergeField from "#lib/components/tags/MergeField.svelte";
|
||||||
import RenameField from "#lib/components/tags/RenameField.svelte";
|
import RenameField from "#lib/components/tags/RenameField.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +39,7 @@
|
|||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { signedAmount } from "#lib/transactions/split.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -53,24 +63,57 @@
|
|||||||
|
|
||||||
const counts = createQuery(() => ({
|
const counts = createQuery(() => ({
|
||||||
enabled: cleanup && Boolean(tags.data),
|
enabled: cleanup && Boolean(tags.data),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () => usage(tags.data ?? []),
|
queryFn: () => usage(tags.data ?? []),
|
||||||
queryKey: ["tags-usage", (tags.data ?? []).map((t) => t.id).join(",")],
|
queryKey: ["tags-usage", (tags.data ?? []).map((t) => t.id).join(",")],
|
||||||
}));
|
}));
|
||||||
|
const categories = createQuery(() => lists.categories());
|
||||||
|
const bills = createQuery(() => lists.bills());
|
||||||
|
const expenses = createQuery(() => ({
|
||||||
|
...lists.accounts("expense"),
|
||||||
|
enabled: cleanup,
|
||||||
|
}));
|
||||||
|
const found = $derived(
|
||||||
|
cleanup
|
||||||
|
? findings(tags.data ?? [], {
|
||||||
|
accounts: expenses.data ?? [],
|
||||||
|
bills: bills.data ?? [],
|
||||||
|
categories: categories.data ?? [],
|
||||||
|
usage: counts.data,
|
||||||
|
})
|
||||||
|
: new Map<string, Finding>()
|
||||||
|
);
|
||||||
|
|
||||||
const items = $derived(splitRows(groups.data ?? []));
|
const items = $derived(splitRows(groups.data ?? []));
|
||||||
const stats = $derived(tagStats(items, !(flat || filter)));
|
const stats = $derived(tagStats(items, !(flat || filter)));
|
||||||
|
const rank = (node: TagNode) => rankOf(found.get(node.tag?.id ?? ""));
|
||||||
const nodes = $derived.by(() => {
|
const nodes = $derived.by(() => {
|
||||||
const source = tags.data ?? [];
|
const source = tags.data ?? [];
|
||||||
if (filter) {
|
const matched = filter
|
||||||
return flatNodes(
|
? fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
|
||||||
fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
|
: source;
|
||||||
);
|
if (cleanup) {
|
||||||
|
return flatNodes(matched).sort((a, b) => rank(a) - rank(b));
|
||||||
}
|
}
|
||||||
return flat ? flatNodes(source) : flatten(buildTree(source));
|
if (filter || flat) {
|
||||||
|
return flatNodes(matched);
|
||||||
|
}
|
||||||
|
return flatten(buildTree(source));
|
||||||
});
|
});
|
||||||
|
const sectionOf = (i: number) =>
|
||||||
|
found.get(nodes[i]?.tag?.id ?? "")?.section ?? "rest";
|
||||||
|
const sectionSize = $derived(
|
||||||
|
nodes.reduce<Record<string, number>>((sizes, _, i) => {
|
||||||
|
sizes[sectionOf(i)] = (sizes[sectionOf(i)] ?? 0) + 1;
|
||||||
|
return sizes;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
const clusters = $derived(cleanup ? duplicates(tags.data ?? []) : []);
|
const clusters = $derived(cleanup ? duplicates(tags.data ?? []) : []);
|
||||||
|
|
||||||
let editing = $state<{ path: string; mode: "rename" | "merge" } | null>(null);
|
let editing = $state<{
|
||||||
|
path: string;
|
||||||
|
mode: "rename" | "merge" | "category" | "bill";
|
||||||
|
} | null>(null);
|
||||||
let armed = $state<string | null>(null);
|
let armed = $state<string | null>(null);
|
||||||
let armTimer: ReturnType<typeof setTimeout> | undefined;
|
let armTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let rowStatus = $state<Record<string, string>>({});
|
let rowStatus = $state<Record<string, string>>({});
|
||||||
@@ -99,6 +142,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convert(node: TagNode | undefined, mode: "category" | "bill") {
|
||||||
|
if (node?.tag) {
|
||||||
|
armed = null;
|
||||||
|
editing = { mode, path: node.path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function choicesFor(mode: "category" | "bill") {
|
||||||
|
if (mode === "category") {
|
||||||
|
return (categories.data ?? []).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.attributes.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return (bills.data ?? []).map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.attributes.name ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(finding: Finding | undefined) {
|
||||||
|
return [
|
||||||
|
finding?.category && {
|
||||||
|
href: `/categories/show/${finding.category.id}`,
|
||||||
|
kind: "category",
|
||||||
|
name: finding.category.attributes.name,
|
||||||
|
},
|
||||||
|
finding?.account && {
|
||||||
|
href: `/accounts/show/${finding.account.id}`,
|
||||||
|
kind: "account",
|
||||||
|
name: finding.account.attributes.name,
|
||||||
|
},
|
||||||
|
finding?.bill && {
|
||||||
|
href: `/subscriptions/show/${finding.bill.id}`,
|
||||||
|
kind: "subscription",
|
||||||
|
name: finding.bill.attributes.name,
|
||||||
|
},
|
||||||
|
].filter((match) => match !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialFor(tag: Item<Tag>, mode: "category" | "bill") {
|
||||||
|
const finding = found.get(tag.id);
|
||||||
|
const match =
|
||||||
|
mode === "bill"
|
||||||
|
? finding?.bill?.attributes.name
|
||||||
|
: finding?.category?.attributes.name;
|
||||||
|
return match ?? leafOf(tag.attributes.tag);
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(node: TagNode) {
|
async function remove(node: TagNode) {
|
||||||
if (!node.tag) {
|
if (!node.tag) {
|
||||||
return;
|
return;
|
||||||
@@ -166,6 +258,18 @@
|
|||||||
label: "Merge tag into another",
|
label: "Merge tag into another",
|
||||||
run: () => merge(nav.current),
|
run: () => merge(nav.current),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "s c",
|
||||||
|
label: "Move tag's transactions to a category",
|
||||||
|
run: () => convert(nav.current, "category"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "s s",
|
||||||
|
label: "Link tag's transactions to a subscription",
|
||||||
|
run: () => convert(nav.current, "bill"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
group: "Tags",
|
group: "Tags",
|
||||||
keys: "d d",
|
keys: "d d",
|
||||||
@@ -205,6 +309,38 @@
|
|||||||
nav.use();
|
nav.use();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet reason(
|
||||||
|
finding: Finding | undefined
|
||||||
|
)}
|
||||||
|
<span class="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
{#each matches(finding) as match (match.kind)}
|
||||||
|
<span
|
||||||
|
>{match.kind}
|
||||||
|
<a class="text-brand hover:underline" href={match.href}
|
||||||
|
>{match.name}</a
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if finding?.sample}
|
||||||
|
{@const split = finding.sample.attributes.transactions[0]}
|
||||||
|
{@const money = signedAmount(split)}
|
||||||
|
<a
|
||||||
|
class="max-w-64 truncate text-cladd-fg hover:underline"
|
||||||
|
href="/transactions/show/{finding.sample.id}"
|
||||||
|
>{split.description}</a
|
||||||
|
>
|
||||||
|
<Amount
|
||||||
|
amount={money.amount}
|
||||||
|
code={money.code}
|
||||||
|
decimals={money.decimals}
|
||||||
|
symbol={money.symbol}
|
||||||
|
tone={money.tone}
|
||||||
|
/>
|
||||||
|
<span class="num">{formatDate(split.date.slice(0, 10))}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Tags" }]} title="Tags">
|
<PageHeader crumbs={[{ label: "Tags" }]} title="Tags">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/tags/create" size="sm" variant="outline">
|
<Button href="/tags/create" size="sm" variant="outline">
|
||||||
@@ -219,19 +355,21 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#if cleanup}
|
{#if cleanup}
|
||||||
<Duplicates {clusters} counts={counts.data ?? new Map()} />
|
<Duplicates {clusters} counts={counts.data?.counts ?? new Map()} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Box body="p-0" title="Tags">
|
<Box body="p-0" title="Tags">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if !(flat || filter)}
|
{#if !(flat || filter || cleanup)}
|
||||||
<span class="hidden text-[12px] text-cladd-fg-soft md:inline"
|
<span class="hidden text-[12px] text-cladd-fg-soft md:inline"
|
||||||
>parent rows include sub-tags</span
|
>parent rows include sub-tags</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px]"
|
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px] {cleanup
|
||||||
|
? "hidden"
|
||||||
|
: ""}"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-pressed={!flat}
|
aria-pressed={!flat}
|
||||||
@@ -299,8 +437,12 @@
|
|||||||
{counts.isFetching ? "Counting" : "All time"}
|
{counts.isFetching ? "Counting" : "All time"}
|
||||||
</th>
|
</th>
|
||||||
{/if}
|
{/if}
|
||||||
<th class="hidden px-3 font-medium md:table-cell">Date</th>
|
{#if cleanup}
|
||||||
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
|
<th class="px-3 font-medium">Reason</th>
|
||||||
|
{:else}
|
||||||
|
<th class="hidden px-3 font-medium md:table-cell">Date</th>
|
||||||
|
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<ListMenu
|
<ListMenu
|
||||||
@@ -323,6 +465,16 @@
|
|||||||
label: "Merge into another tag",
|
label: "Merge into another tag",
|
||||||
run: () => merge(nav.current),
|
run: () => merge(nav.current),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
keys: "s c",
|
||||||
|
label: "Move to a category",
|
||||||
|
run: () => convert(nav.current, "category"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "s s",
|
||||||
|
label: "Link to a subscription",
|
||||||
|
run: () => convert(nav.current, "bill"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Open in old UI",
|
label: "Open in old UI",
|
||||||
run: () =>
|
run: () =>
|
||||||
@@ -341,8 +493,24 @@
|
|||||||
{#each nodes as node, i (node.path)}
|
{#each nodes as node, i (node.path)}
|
||||||
{@const stat = stats.get(node.path)}
|
{@const stat = stats.get(node.path)}
|
||||||
{@const total = node.tag
|
{@const total = node.tag
|
||||||
? counts.data?.get(node.tag.id)
|
? counts.data?.counts.get(node.tag.id)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
{@const finding = found.get(node.tag?.id ?? "")}
|
||||||
|
{#if cleanup && (i === 0 || sectionOf(i - 1) !== sectionOf(i))}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline border-b bg-cladd-surface-next"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-3 font-semibold text-[12px] text-cladd-fg-soft"
|
||||||
|
colspan="7"
|
||||||
|
>
|
||||||
|
{SECTIONS[sectionOf(i)]}
|
||||||
|
<span class="num ml-1 font-normal"
|
||||||
|
>{sectionSize[sectionOf(i)]}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
<tr
|
<tr
|
||||||
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||||
i
|
i
|
||||||
@@ -371,6 +539,22 @@
|
|||||||
tag={node.tag}
|
tag={node.tag}
|
||||||
tags={tags.data ?? []}
|
tags={tags.data ?? []}
|
||||||
/>
|
/>
|
||||||
|
{:else if editing?.path === node.path &&
|
||||||
|
(editing.mode === "category" ||
|
||||||
|
editing.mode === "bill") &&
|
||||||
|
node.tag}
|
||||||
|
<ConvertField
|
||||||
|
choices={choicesFor(editing.mode)}
|
||||||
|
initial={initialFor(node.tag, editing.mode)}
|
||||||
|
kind={editing.mode}
|
||||||
|
ondone={(status) => {
|
||||||
|
editing = null;
|
||||||
|
if (status) {
|
||||||
|
rowStatus[node.path] = status;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tag={node.tag}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
class="hover:underline {node.tag
|
class="hover:underline {node.tag
|
||||||
@@ -419,18 +603,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{/if}
|
{/if}
|
||||||
<td
|
{#if cleanup}
|
||||||
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
|
<td class="px-3 text-[12px] text-cladd-fg-soft">
|
||||||
>
|
{@render reason(finding)}
|
||||||
{node.tag?.attributes.date
|
</td>
|
||||||
? formatDate(node.tag.attributes.date)
|
{:else}
|
||||||
: ""}
|
<td
|
||||||
</td>
|
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
|
||||||
<td
|
>
|
||||||
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
|
{node.tag?.attributes.date
|
||||||
>
|
? formatDate(node.tag.attributes.date)
|
||||||
{node.tag?.attributes.description ?? ""}
|
: ""}
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
|
||||||
|
>
|
||||||
|
{node.tag?.attributes.description ?? ""}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#if !tags.isPending}
|
{#if !tags.isPending}
|
||||||
@@ -465,6 +655,14 @@
|
|||||||
>
|
>
|
||||||
<span><span class="kbd">r</span> rename</span>
|
<span><span class="kbd">r</span> rename</span>
|
||||||
<span><span class="kbd">m</span> merge</span>
|
<span><span class="kbd">m</span> merge</span>
|
||||||
|
<span
|
||||||
|
><span class="kbd">s</span><span class="kbd">c</span>
|
||||||
|
to category</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><span class="kbd">s</span><span class="kbd">s</span>
|
||||||
|
to subscription</span
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
><span class="kbd">d</span><span class="kbd">d</span>
|
><span class="kbd">d</span><span class="kbd">d</span>
|
||||||
delete</span
|
delete</span
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
} from "#lib/transactions/split.js";
|
} from "#lib/transactions/split.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { loadPast, placesOf, usualOf, verdict } from "./usual.js";
|
||||||
|
|
||||||
interface Linked {
|
interface Linked {
|
||||||
amount: Signed | null;
|
amount: Signed | null;
|
||||||
@@ -166,6 +167,14 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const places = $derived(placesOf(splits));
|
||||||
|
const groupDay = $derived(splits[0]?.date.slice(0, 10) ?? "");
|
||||||
|
const pastQuery = createQuery(() => ({
|
||||||
|
enabled: places.length > 0,
|
||||||
|
queryFn: () => loadPast(places, id, groupDay),
|
||||||
|
queryKey: ["transaction-usual", id, places.join(","), groupDay],
|
||||||
|
}));
|
||||||
|
|
||||||
const linksQuery = createQuery(() => ({
|
const linksQuery = createQuery(() => ({
|
||||||
enabled: journals.length > 0,
|
enabled: journals.length > 0,
|
||||||
queryFn: () => loadLinks(journals),
|
queryFn: () => loadLinks(journals),
|
||||||
@@ -495,6 +504,7 @@
|
|||||||
{@const files = attachmentsFor(split)}
|
{@const files = attachmentsFor(split)}
|
||||||
{@const events = eventsFor(split)}
|
{@const events = eventsFor(split)}
|
||||||
{@const links = linksQuery.data?.[journalId(split)] ?? []}
|
{@const links = linksQuery.data?.[journalId(split)] ?? []}
|
||||||
|
{@const usual = usualOf(split, pastQuery.data)}
|
||||||
<div class="flex min-w-0 flex-col gap-4">
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
<Box body="p-0">
|
<Box body="p-0">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
@@ -539,6 +549,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{#if usual}
|
||||||
|
{@const said = verdict(usual.change)}
|
||||||
|
<tr>
|
||||||
|
{@render row("Usual here")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class="flex flex-wrap items-baseline gap-x-1.5">
|
||||||
|
<Amount
|
||||||
|
amount={-usual.median}
|
||||||
|
code={split.currency_code}
|
||||||
|
decimals={split.currency_decimal_places}
|
||||||
|
symbol={split.currency_symbol}
|
||||||
|
/>
|
||||||
|
<span class="text-cladd-fg-soft">median of the</span>
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href="/search?search={encodeURIComponent(usual.query)}"
|
||||||
|
>last {usual.count}</a
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap {said.warn
|
||||||
|
? "text-warn"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
><span class="text-cladd-fg-softer">·</span>
|
||||||
|
{said.text}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
<tr>
|
<tr>
|
||||||
{@render row("Transaction journal ID")}
|
{@render row("Transaction journal ID")}
|
||||||
<td class="num px-3 py-1.5">#{journalId(split)}</td>
|
<td class="num px-3 py-1.5">#{journalId(split)}</td>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Page, Split, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import { addMonths } from "#lib/dates.js";
|
||||||
|
import { isCash } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
const COUNT = 10;
|
||||||
|
const MIN_COUNT = 3;
|
||||||
|
const LOOKBACK_MONTHS = 60;
|
||||||
|
const PERCENT = 100;
|
||||||
|
|
||||||
|
export interface Usual {
|
||||||
|
change: number;
|
||||||
|
count: number;
|
||||||
|
median: number;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const placesOf = (splits: Split[]) => [
|
||||||
|
...new Set(
|
||||||
|
splits
|
||||||
|
.filter(
|
||||||
|
(split) =>
|
||||||
|
split.type === "withdrawal" &&
|
||||||
|
split.destination_id &&
|
||||||
|
!isCash(split.destination_type)
|
||||||
|
)
|
||||||
|
.map((split) => split.destination_id ?? "")
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function loadPast(
|
||||||
|
places: string[],
|
||||||
|
groupId: string,
|
||||||
|
end: string
|
||||||
|
): Promise<Record<string, Split[]>> {
|
||||||
|
const pages = await Promise.all(
|
||||||
|
places.map((place) =>
|
||||||
|
api<Page<TransactionGroup>>(`/v1/accounts/${place}/transactions`, {
|
||||||
|
query: {
|
||||||
|
end,
|
||||||
|
limit: COUNT + 1,
|
||||||
|
start: addMonths(end, -LOOKBACK_MONTHS),
|
||||||
|
type: "withdrawal",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return Object.fromEntries(
|
||||||
|
places.map((place, i) => [
|
||||||
|
place,
|
||||||
|
pages[i].data
|
||||||
|
.filter((group) => group.id !== groupId)
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.attributes.transactions.filter(
|
||||||
|
(split) => split.destination_id === place
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values: number[]) {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usualOf(
|
||||||
|
split: Split,
|
||||||
|
past: Record<string, Split[]> | undefined
|
||||||
|
): Usual | null {
|
||||||
|
const same = (past?.[split.destination_id ?? ""] ?? [])
|
||||||
|
.filter((item) => item.currency_code === split.currency_code)
|
||||||
|
.slice(0, COUNT);
|
||||||
|
if (same.length < MIN_COUNT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const typical = median(same.map((item) => Math.abs(Number(item.amount))));
|
||||||
|
return {
|
||||||
|
change: Math.round((Number(split.amount) / typical - 1) * PERCENT),
|
||||||
|
count: same.length,
|
||||||
|
median: typical,
|
||||||
|
query: `destination_account_id:${split.destination_id} currency_is:${split.currency_code} date_before:${split.date.slice(0, 10)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABOUT = 5;
|
||||||
|
const NOTABLE = 15;
|
||||||
|
|
||||||
|
export function verdict(change: number) {
|
||||||
|
if (Math.abs(change) < ABOUT) {
|
||||||
|
return { text: "about usual", warn: false };
|
||||||
|
}
|
||||||
|
return change > 0
|
||||||
|
? { text: `+${change}% above usual`, warn: change >= NOTABLE }
|
||||||
|
: { text: `${-change}% below usual`, warn: false };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user