From 01c53d982dfa976f6d87bd6a34ae579411e4081a Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:10:47 +0000 Subject: [PATCH] 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. --- src/lib/components/QuickEntry.svelte | 23 +- src/lib/components/editor/quick.ts | 237 ++++++++++++++--- src/lib/components/shell/Palette.svelte | 14 + src/lib/components/tags/ConvertField.svelte | 226 ++++++++++++++++ src/lib/components/tags/MergeField.svelte | 52 ++-- src/lib/components/tags/actions.ts | 53 +++- src/lib/components/tags/findings.ts | 80 ++++++ src/lib/components/tags/tree.ts | 2 +- src/lib/transactions/searches.svelte.ts | 79 ++++++ src/routes/search/+page.svelte | 43 +++ src/routes/tags/+page.svelte | 250 ++++++++++++++++-- .../transactions/show/[id=id]/+page.svelte | 39 +++ src/routes/transactions/show/[id=id]/usual.ts | 97 +++++++ 13 files changed, 1093 insertions(+), 102 deletions(-) create mode 100644 src/lib/components/tags/ConvertField.svelte create mode 100644 src/lib/components/tags/findings.ts create mode 100644 src/lib/transactions/searches.svelte.ts create mode 100644 src/routes/transactions/show/[id=id]/usual.ts diff --git a/src/lib/components/QuickEntry.svelte b/src/lib/components/QuickEntry.svelte index 8c8025b..4559e74 100644 --- a/src/lib/components/QuickEntry.svelte +++ b/src/lib/components/QuickEntry.svelte @@ -26,6 +26,7 @@ tagsOf, } from "#lib/components/editor/quick.js"; 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 { today } from "#lib/dates.js"; import { formatDate, formatMoney } from "#lib/format.js"; @@ -35,7 +36,7 @@ const SUGGESTIONS = 6; 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(); let input = $state(null); @@ -61,6 +62,7 @@ "!": () => catalog.categoryOptions(query), "@": () => catalog.accountOptions(KINDS[quick.type][other], query), "#": () => catalog.tagOptions(query, tagsOf(quick)), + "%": () => catalog.billOptions(query), "^": () => catalog.accountOptions(KINDS[quick.type][own], query), ">": () => catalog.accountOptions(KINDS.transfer.destination, query), "~": () => [], @@ -107,12 +109,18 @@ function money(form: Form) { const [split] = form.splits; - return formatMoney({ - amount: quick.amount ?? 0, - code: split.currency?.code, - decimals: split.currency?.decimals, - symbol: split.currency?.symbol, + const main = formatMoney({ + ...split.currency, + amount: evalAmount(split.amount) ?? 0, }); + if (!split.foreignCurrency) { + return main; + } + const foreign = formatMoney({ + ...split.foreignCurrency, + amount: evalAmount(split.foreignAmount) ?? 0, + }); + return `${main} (${foreign})`; } function syncCaret() { @@ -252,6 +260,9 @@ text: `category ${split.category}${fresh}${last(fromPast.category)}`, }); } + if (split.bill.id) { + parts.push({ text: `subscription ${split.bill.name}` }); + } if (split.budget.id) { parts.push({ text: `budget ${split.budget.name}${last(fromPast.budget)}`, diff --git a/src/lib/components/editor/quick.ts b/src/lib/components/editor/quick.ts index e4ebb57..312da84 100644 --- a/src/lib/components/editor/quick.ts +++ b/src/lib/components/editor/quick.ts @@ -1,8 +1,11 @@ import type { Split } from "#lib/api/types.js"; import { evalAmount, parseDate } from "#lib/components/inputs/parse.js"; import { today } from "#lib/dates.js"; +import { session } from "#lib/session.svelte.js"; import type { Catalog } from "./catalog.svelte.js"; import { + type AccountRef, + type Cur, emptyForm, KINDS, OWN_SIDE, @@ -13,9 +16,9 @@ import { toAccountRef, } from "./model.js"; -export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~"; +export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~"; -const MULTI = new Set(["@", "^", ">", "!", "$"]); +const MULTI = new Set(["@", "^", ">", "!", "$", "%"]); const MARKERS = new Set([...MULTI, "#", "~"]); const WORD = /\S+/g; const NUMERIC = /^[\d(]/; @@ -27,21 +30,109 @@ export interface Segment { value: string; } +export interface Equal { + code: string; + text: string; +} + export interface Quick { amount: number | null; amountText: string; + currency: string; description: string; + equal: Equal | null; segments: Segment[]; type: TxType; } -export function parseQuick(input: string): Quick { - const segments: Segment[] = []; - const words: string[] = []; - let amount: number | null = null; - let amountText = ""; +const enabledCodes = () => + new Set( + session.currencies + .filter((item) => item.attributes.enabled) + .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) { + 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 current: Segment | null = null; let first = true; for (const match of input.matchAll(WORD)) { let [word] = match; @@ -52,42 +143,32 @@ export function parseQuick(input: string): Quick { start += 1; } first = false; - if (!word) { - continue; + const taken = + !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"; if (hasTarget) { type = "transfer"; } else if (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) => @@ -158,6 +239,77 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) { : [`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( quick: Quick, catalog: Catalog, @@ -199,10 +351,17 @@ export function resolveQuick( split, 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.amount = quick.amountText; - split.currency = split[own].currency; split.description = quick.description || split[other].name || diff --git a/src/lib/components/shell/Palette.svelte b/src/lib/components/shell/Palette.svelte index 864d3f2..81c7805 100644 --- a/src/lib/components/shell/Palette.svelte +++ b/src/lib/components/shell/Palette.svelte @@ -5,6 +5,7 @@ import { fuzzyFilter } from "#lib/fuzzy.js"; import { keys, prettyKeys } from "#lib/keys.svelte.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 { goto } from "$app/navigation"; @@ -214,8 +215,21 @@ ), ]); + const searchEntries = $derived( + 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([ ...pageEntries, + ...searchEntries, ...staticEntries, ...commandEntries, ...entityEntries, diff --git a/src/lib/components/tags/ConvertField.svelte b/src/lib/components/tags/ConvertField.svelte new file mode 100644 index 0000000..b58d15f --- /dev/null +++ b/src/lib/components/tags/ConvertField.svelte @@ -0,0 +1,226 @@ + + +
+ To {NOUN[kind]} + { + active = -1; + fresh = ""; + problem = ""; + }} + {onkeydown} + readonly={Boolean(busy || done)} + bind:value + {@attach focus} + > + {#if options.length > 0} + + Tab + {#each options as option, i (option.id)} + { + event.preventDefault(); + run(option); + }} + role="option" + tabindex="-1" + > + {option.name} + + {/each} + + {/if} + + {#if busy} + {busy} + {:else if done} + {done}. + ↵ + delete the tag Esc keep it + {:else if fresh} + No {NOUN[kind]} called "{fresh}" yet. ↵ + again creates it + {:else} + ↵ + move its transactions Esc cancel + {/if} + + {#if problem} + {problem} + {/if} +
diff --git a/src/lib/components/tags/MergeField.svelte b/src/lib/components/tags/MergeField.svelte index b0e8dee..6994876 100644 --- a/src/lib/components/tags/MergeField.svelte +++ b/src/lib/components/tags/MergeField.svelte @@ -71,18 +71,42 @@ } -
+
Merge into (cursor = 0)} {onkeydown} placeholder="tag name" + readonly={busy} bind:value {@attach focus} > + {#if !busy && options.length > 0} + + {#each options as option, i (option.id)} + { + event.preventDefault(); + run(option.attributes.tag); + }} + role="option" + tabindex="-1" + > + {option.attributes.tag} + + {/each} + + {/if} {#if busy && outcome} Moving {outcome.done} of {outcome.total} transactions @@ -96,28 +120,4 @@ {#if problem} {problem} {/if} - {#if !busy && options.length > 0} -
- {#each options as option, i (option.id)} -
{ - event.preventDefault(); - run(option.attributes.tag); - }} - role="option" - tabindex="-1" - > - {option.attributes.tag} -
- {/each} -
- {/if}
diff --git a/src/lib/components/tags/actions.ts b/src/lib/components/tags/actions.ts index d8e2eed..67d996f 100644 --- a/src/lib/components/tags/actions.ts +++ b/src/lib/components/tags/actions.ts @@ -1,12 +1,24 @@ import { api } from "#lib/api/client.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 { type Outcome, + type Patch, type Targets, updateGroups, } from "#lib/transactions/mass.js"; +export interface Usage { + counts: Map; + samples: Map>; +} + const lower = (tag: Item) => tag.attributes.tag.toLowerCase(); const tagged = (groups: Item[], name: string) => @@ -61,7 +73,31 @@ export async function mergeTag( return outcome; } -export async function usage(tags: Item[]): Promise> { +export async function convertTag( + tag: Item, + patch: (split: Split) => Patch | null, + progress: (outcome: Outcome) => void +): Promise<{ outcome: Outcome; splits: Split[] }> { + const name = tag.attributes.tag; + const groups = tagged( + await all(`/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[]): Promise { const keys = tags.map(lower); const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i)); const plain = tags.filter((tag) => !clash.has(lower(tag))); @@ -75,6 +111,11 @@ export async function usage(tags: Item[]): Promise> { const counts = new Map( 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( [...clash].map(async (key) => { const variants = tags.filter((tag) => lower(tag) === key); @@ -83,11 +124,15 @@ export async function usage(tags: Item[]): Promise> { { type: "all" } ); 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(items: T[], run: (item: T) => Promise) { diff --git a/src/lib/components/tags/findings.ts b/src/lib/components/tags/findings.ts new file mode 100644 index 0000000..c55f7b3 --- /dev/null +++ b/src/lib/components/tags/findings.ts @@ -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; + bill?: Item; + category?: Item; + sample?: Item; + section: Section; +} + +export const SECTIONS: Record = { + 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, count?: number) { + if (finding.account || finding.bill || finding.category) { + return "same"; + } + if (count === 1) { + return "once"; + } + return count === 0 ? "unused" : "rest"; +} + +function index(items: Item[], name: (item: T) => string) { + return new Map(items.map((item) => [squash(name(item.attributes)), item])); +} + +export function findings( + tags: Item[], + lists: { + accounts: Item[]; + bills: Item[]; + categories: Item[]; + usage?: Usage; + } +): Map { + 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) }]; + }) + ); +} diff --git a/src/lib/components/tags/tree.ts b/src/lib/components/tags/tree.ts index c6b815c..16a3396 100644 --- a/src/lib/components/tags/tree.ts +++ b/src/lib/components/tags/tree.ts @@ -134,7 +134,7 @@ export function tagStats(items: Row[], rollup: boolean): Map { const normal = (value: string) => 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 { const rows = a.length + 1; diff --git a/src/lib/transactions/searches.svelte.ts b/src/lib/transactions/searches.svelte.ts new file mode 100644 index 0000000..f90019f --- /dev/null +++ b/src/lib/transactions/searches.svelte.ts @@ -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(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(); diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte index a2bc609..ca2344b 100644 --- a/src/routes/search/+page.svelte +++ b/src/routes/search/+page.svelte @@ -13,6 +13,7 @@ import { useKeys } from "#lib/keys.svelte.js"; import { session } from "#lib/session.svelte.js"; import { explain, operatorsQuery } from "#lib/transactions/query.js"; + import { searches } from "#lib/transactions/searches.svelte.js"; import { goto } from "$app/navigation"; import { page } from "$app/state"; @@ -30,6 +31,7 @@ let input = $state(); let seconds = $state(0); + let pinNote = $state({ query: "", text: "" }); const query = $derived(page.url.searchParams.get("search")?.trim() ?? ""); const pageNo = $derived( @@ -58,6 +60,7 @@ })); const pagination = $derived(results.data?.meta.pagination); + const pinned = $derived(searches.has(query)); const explanation = $derived(explain(query, operators.data ?? [])); function submit(text: string) { @@ -83,6 +86,18 @@ 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(() => { if (input && query === "") { input.focus(); @@ -91,6 +106,12 @@ useKeys(() => [ { group: "Global", keys: "/", label: "Focus search", run: focus }, + { + group: "Search", + keys: "P", + label: pinned ? "Unpin this search" : "Pin this search", + run: togglePin, + }, ]); @@ -118,6 +139,14 @@ /> {#if query} + + {#if pinNote.query === query && pinNote.text} + {pinNote.text} + {/if} Tab or Enter accepts a suggestion.

+

+ Saved: + {#each searches.list() as saved (saved.query)} + { + event.preventDefault(); + searches.open(saved); + }} + >{saved.label} + {/each} +

{/if} diff --git a/src/routes/tags/+page.svelte b/src/routes/tags/+page.svelte index 8799aba..a14d515 100644 --- a/src/routes/tags/+page.svelte +++ b/src/routes/tags/+page.svelte @@ -1,9 +1,10 @@ +{#snippet reason( + finding: Finding | undefined +)} + + {#each matches(finding) as match (match.kind)} + {match.kind} + {match.name} + {/each} + {#if finding?.sample} + {@const split = finding.sample.attributes.transactions[0]} + {@const money = signedAmount(split)} + {split.description} + + {formatDate(split.date.slice(0, 10))} + {/if} + +{/snippet} + {#snippet actions()}