feat(transactions,search): dense split-aware tables, mass edit, query-language search and show page

This commit is contained in:
hh committed 2026-10-16 03:33:54 +00:00
1 parent 7e19684cff
commit 772047c0a1
16 files changed
+3300 -2

No files matched your search

@@ -0,0 +1,293 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Split } from "#lib/api/types.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import {
deleteTargets,
type Outcome,
type Patch,
type Row,
splitsOf,
targetsOf,
updateGroups,
} from "#lib/transactions/mass.js";
import type { Suggestion } from "#lib/transactions/query.js";
import SuggestInput from "./SuggestInput.svelte";
export type Mode = "category" | "budget" | "tag" | "untag";
const ARM_MS = 3000;
const STATUS_MS = 8000;
const NONE = "\u0000";
const MODES: { mode: Mode; keys: string; label: string; verb: string }[] = [
{ keys: "s c", label: "Category", mode: "category", verb: "Set category" },
{ keys: "s b", label: "Budget", mode: "budget", verb: "Set budget" },
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
];
const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb]));
let {
targets,
selected,
onclear,
}: { targets: Row[]; selected: number; onclear: () => void } = $props();
let mode = $state<Mode | null>(null);
let active = $state<Row[]>([]);
let text = $state("");
let input = $state<HTMLInputElement>();
let status = $state("");
let busy = $state(false);
let armed = $state(false);
let armTimer: ReturnType<typeof setTimeout> | undefined;
let statusTimer: ReturnType<typeof setTimeout> | undefined;
const categories = createQuery(() => ({
...lists.categories(),
enabled: mode === "category",
}));
const budgets = createQuery(() => ({
...lists.budgets(),
enabled: mode === "budget",
}));
const tags = createQuery(() => ({
...lists.tags(),
enabled: mode === "tag",
}));
const visible = $derived(
selected > 0 || mode !== null || armed || status !== ""
);
const count = $derived(targetsOf(mode || armed ? active : targets).size);
function options(current: Mode | null): [string[], string | null, boolean] {
switch (current) {
case "category":
return [
(categories.data ?? []).map((c) => c.attributes.name),
"(no category)",
true,
];
case "budget":
return [
(budgets.data ?? [])
.filter((b) => b.attributes.active !== false)
.map((b) => b.attributes.name),
"(no budget)",
false,
];
case "tag":
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
case "untag":
return [
[...new Set(splitsOf(active).flatMap((s) => s.tags ?? []))],
null,
false,
];
default:
return [[], null, false];
}
}
const suggestions = $derived.by<Suggestion[]>(() => {
const [names, none, free] = options(mode);
const query = text.trim();
const found: Suggestion[] = fuzzyFilter(names, query, (n) => n, 8).map(
(name) => ({ id: name, insert: name, label: name })
);
const exact = names.some((n) => n.toLowerCase() === query.toLowerCase());
if (free && query && !exact) {
found.push({
hint: "new",
id: `new:${query}`,
insert: query,
label: query,
});
}
if (none && !query) {
found.unshift({ id: NONE, insert: NONE, label: none });
}
return found;
});
function say(message: string) {
status = message;
clearTimeout(statusTimer);
if (!busy) {
statusTimer = setTimeout(() => (status = ""), STATUS_MS);
}
}
function report(verb: string, outcome: Outcome) {
const errors = outcome.errors.length
? `. Failed: ${outcome.errors.join("; ")}`
: "";
say(`${verb} ${outcome.done} of ${outcome.total}${errors}`);
}
export async function start(next: Mode, rows: Row[] = targets) {
if (rows.length === 0 || busy) {
return;
}
active = rows;
mode = next;
text = "";
status = "";
await tick();
input?.focus();
}
function cancel() {
mode = null;
text = "";
}
function patcher(current: Mode, value: string | null) {
const budgetId =
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null;
const patches: Record<Mode, (split: Split) => Patch | null> = {
budget: (split) =>
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId
? null
: { budget_id: budgetId },
category: (split) =>
(split.category_name ?? null) === value
? null
: { category_name: value },
tag: (split) =>
!value || split.tags?.includes(value)
? null
: { tags: [...(split.tags ?? []), value] },
untag: (split) =>
value && split.tags?.includes(value)
? { tags: split.tags.filter((tag) => tag !== value) }
: null,
};
return patches[current];
}
async function apply(raw: string) {
if (!mode) {
return;
}
const value = raw === NONE ? null : raw.trim() || null;
if (value === null && (mode === "tag" || mode === "untag")) {
return;
}
const verb = VERB[mode];
const patch = patcher(mode, value);
const rows = active;
mode = null;
busy = true;
const outcome = await updateGroups(targetsOf(rows), patch, (progress) =>
say(`${verb}: updated ${progress.done} of ${progress.total}`)
);
await invalidate();
busy = false;
report("Updated", outcome);
}
export async function remove(rows: Row[] = targets, confirmed = false) {
if (busy || rows.length === 0) {
return;
}
if (confirmed) {
active = rows;
} else if (!armed) {
active = rows;
armed = true;
clearTimeout(armTimer);
armTimer = setTimeout(() => (armed = false), ARM_MS);
return;
}
clearTimeout(armTimer);
armed = false;
busy = true;
const outcome = await deleteTargets(targetsOf(active), (progress) =>
say(`Deleted ${progress.done} of ${progress.total}`)
);
onclear();
await invalidate();
busy = false;
report("Deleted", outcome);
}
</script>
{#if visible}
<div
aria-label="Selection actions"
class="m-2 flex min-h-10 flex-wrap items-center gap-2 rounded-cladd-md bg-row-selected/50 px-2.5 py-1.5 text-[12px]"
role="toolbar"
>
<span class="font-semibold text-cladd-fg">
{count}
{count === 1 ? "transaction" : "transactions"}
{selected > 0 && !mode ? "selected" : ""}
</span>
{#if mode}
<label class="text-cladd-fg-soft" for="action-bar-input"
>{VERB[mode]}:</label
>
<SuggestInput
class="w-72"
id="action-bar-input"
onaccept={(item) => {
apply(item.insert);
return true;
}}
onblur={cancel}
oncancel={cancel}
onsubmit={() => apply(text)}
placeholder="Type to filter"
{suggestions}
bind:input
bind:value={text}
/>
<span class="text-cladd-fg-softer"
><span class="kbd">↵</span>
apply <span class="kbd">Esc</span> cancel</span
>
{:else}
{#each MODES as item (item.mode)}
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-cladd-fg disabled:opacity-50"
disabled={busy}
onclick={() => start(item.mode)}
type="button"
>
{item.label}
<span class="kbd">{item.keys}</span>
</button>
{/each}
<button
aria-live="polite"
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold disabled:opacity-50 {armed
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"}"
disabled={busy}
onclick={() => remove()}
type="button"
>
{#if armed}
Press again to delete {count}
{:else}
Delete <span class="kbd">d</span>
{/if}
</button>
{#if selected > 0}
<button
class="inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2 text-cladd-fg-soft hover:text-cladd-fg"
onclick={onclear}
type="button"
>
Clear <span class="kbd">u</span>
</button>
{/if}
{/if}
<span aria-live="polite" class="ml-auto text-cladd-fg-soft">{status}</span>
</div>
{/if}
@@ -0,0 +1,93 @@
<script lang="ts">
import { useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
let {
page,
pages,
href,
keys = false,
}: {
page: number;
pages: number;
href: (page: number) => string;
keys?: boolean;
} = $props();
const numbers = $derived.by(() => {
const list: (number | null)[] = [];
for (let n = 1; n <= pages; n += 1) {
if (n === 1 || n === pages || Math.abs(n - page) <= 2) {
list.push(n);
} else if (list.at(-1) !== null) {
list.push(null);
}
}
return list;
});
function go(target: number) {
if (target >= 1 && target <= pages && target !== page) {
goto(href(target));
}
}
useKeys(() =>
keys
? [
{
group: "List",
keys: ">",
label: "Next page",
run: () => go(page + 1),
},
{
group: "List",
keys: "<",
label: "Previous page",
run: () => go(page - 1),
},
]
: []
);
</script>
{#if pages > 1}
<nav aria-label="Pages" class="flex items-center gap-0.5 text-[12px]">
{#if page > 1}
<a
class="rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
href={href(page - 1)}
rel="prev"
title="Previous page <"
>Previous</a
>
{/if}
{#each numbers as n, i (n ?? `gap-${i}`)}
{#if n === null}
<span class="px-1 text-cladd-fg-softer">...</span>
{:else if n === page}
<span
aria-current="page"
class="num rounded-cladd-sm bg-row-selected px-2 py-1 font-semibold text-cladd-fg"
>{n}</span
>
{:else}
<a
class="num rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
href={href(n)}
>{n}</a
>
{/if}
{/each}
{#if page < pages}
<a
class="rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
href={href(page + 1)}
rel="next"
title="Next page >"
>Next</a
>
{/if}
</nav>
{/if}
@@ -0,0 +1,154 @@
<script lang="ts">
import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
type Group,
type Signed,
signedAmount,
sumByCurrency,
} from "#lib/transactions/split.js";
const TOP = 5;
const TOTALS = [
["withdrawal", "Spent"],
["deposit", "Earned"],
["transfer", "Transferred"],
] as const;
interface Entry extends Signed {
href?: string;
name: string;
}
let {
groups,
type,
loading = false,
}: { groups: Group[]; type: string; loading?: boolean } = $props();
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
const totals = $derived(
TOTALS.map(([kind, label]) => ({
label,
sums: sumByCurrency(splits.filter((s) => s.type === kind)),
})).filter((row) => row.sums.length > 0)
);
function aggregate(
list: Split[],
pick: (split: Split) => [string, string | undefined]
): { entries: Entry[]; more: number } {
const map = new Map<string, Entry>();
for (const split of list) {
const [name, href] = pick(split);
const money = signedAmount(split);
const key = `${name}|${money.code}`;
const entry = map.get(key);
if (entry) {
entry.amount += money.amount;
} else {
map.set(key, { ...money, href, name });
}
}
const sorted = [...map.values()].sort(
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
);
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
}
const blocks = $derived([
{
title: "Categories",
...aggregate(splits, (s) =>
s.category_id
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
: ["(no category)", undefined]
),
},
type === "withdrawal"
? {
title: "Budgets",
...aggregate(splits, (s) =>
s.budget_id
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
: ["(no budget)", undefined]
),
}
: {
title: "Source accounts",
...aggregate(splits, (s) => [
s.source_name ?? "(cash)",
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
]),
},
{
title: "Destination accounts",
...aggregate(splits, (s) => [
s.destination_name ?? "(cash)",
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
]),
},
]);
</script>
{#snippet amount(
value: Signed
)}
<Amount
amount={value.amount}
code={value.code}
decimals={value.decimals}
symbol={value.symbol}
tone={value.tone === "transfer" ? "transfer" : "auto"}
/>
{/snippet}
<div class="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Box body="px-3 py-2" title="Overview">
{#if loading && groups.length === 0}
<p class="text-cladd-fg-soft">Loading...</p>
{:else}
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt class="text-cladd-fg-soft">Transactions</dt>
<dd class="num text-right">{groups.length}</dd>
{#each totals as row (row.label)}
<dt class="text-cladd-fg-soft">{row.label}</dt>
<dd class="flex flex-col items-end">
{#each row.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
</dd>
{/each}
</dl>
{/if}
</Box>
{#each blocks as block (block.title)}
<Box body="py-1" class="max-md:hidden" title={block.title}>
<ul>
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
<li class="flex h-6 items-center justify-between gap-3 px-3">
{#if entry.href}
<a class="truncate hover:underline" href={entry.href}
>{entry.name}</a
>
{:else}
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
{/if}
{@render amount(entry)}
</li>
{:else}
<li class="px-3 py-1 text-cladd-fg-soft">
{loading ? "Loading..." : "Nothing in this period."}
</li>
{/each}
{#if block.more > 0}
<li class="px-3 text-[12px] text-cladd-fg-softer">
and {block.more} more
</li>
{/if}
</ul>
</Box>
{/each}
</div>
@@ -0,0 +1,150 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import { lists } from "#lib/api/lists.js";
import { addDays, today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import {
kindOf,
operatorSuggestions,
operatorsQuery,
replaceToken,
type Suggestion,
TYPES,
tokenAt,
type ValueKind,
valueSuggestions,
} from "#lib/transactions/query.js";
import SuggestInput from "./SuggestInput.svelte";
let {
value = $bindable(""),
input = $bindable(),
onsubmit,
id = "query",
placeholder = "Search transactions, e.g. category_is:Groceries amount_more:20",
class: className = "",
}: {
value?: string;
input?: HTMLInputElement;
onsubmit: (query: string) => void;
id?: string;
placeholder?: string;
class?: string;
} = $props();
let caret = $state(0);
let used = $state(false);
const operators = createQuery(() => ({ ...operatorsQuery(), enabled: used }));
const categories = createQuery(() => ({
...lists.categories(),
enabled: used,
}));
const budgets = createQuery(() => ({ ...lists.budgets(), enabled: used }));
const tags = createQuery(() => ({ ...lists.tags(), enabled: used }));
const bills = createQuery(() => ({ ...lists.bills(), enabled: used }));
const accounts = createQuery(() => ({
...lists.accounts("all"),
enabled: used,
}));
function dateValues(): [string[], Record<string, string>] {
const now = today();
const hints: Record<string, string> = {
[addDays(now, -1)]: "yesterday",
[now]: "today",
[period.end]: "period end",
[period.start]: "period start",
};
return [
[
"today",
"yesterday",
"start of this month",
"end of this month",
"start of this year",
period.start,
period.end,
now,
"-7d",
"-1m",
],
hints,
];
}
function names<T>(
items: { attributes: T }[] | undefined,
pick: (attributes: T) => string | null | undefined
): string[] {
return (items ?? [])
.map((item) => pick(item.attributes))
.filter((name): name is string => Boolean(name));
}
function values(kind: ValueKind): [string[], Record<string, string>] {
switch (kind) {
case "category":
return [names(categories.data, (c) => c.name), {}];
case "budget":
return [names(budgets.data, (b) => b.name), {}];
case "tag":
return [names(tags.data, (t) => t.tag), {}];
case "bill":
return [names(bills.data, (b) => b.name), {}];
case "account":
return [names(accounts.data, (a) => a.name), {}];
case "currency":
return [names(session.currencies, (c) => c.code), {}];
case "type":
return [TYPES, {}];
case "bool":
return [["true", "false"], {}];
case "date":
return dateValues();
default:
return [[], {}];
}
}
const token = $derived(tokenAt(value, caret));
const suggestions = $derived.by<Suggestion[]>(() => {
if (token.field === null) {
return operatorSuggestions(operators.data ?? [], token);
}
const [list, hints] = values(kindOf(token.field));
return valueSuggestions(token, list, hints);
});
async function accept(item: Suggestion): Promise<void> {
const { caret: position, text } = replaceToken(value, token, item.insert);
value = text;
caret = position;
await tick();
input?.setSelectionRange(position, position);
}
function onaccept(item: Suggestion) {
const current = value.slice(token.start, token.end);
if (current === item.insert.trimEnd() && item.insert.endsWith(" ")) {
return false;
}
accept(item);
return true;
}
</script>
<SuggestInput
class={className}
{id}
{onaccept}
onfocus={() => (used = true)}
onsubmit={() => onsubmit(value.trim())}
{placeholder}
{suggestions}
bind:caret
bind:input
bind:value
/>
@@ -0,0 +1,149 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import type { Suggestion } from "#lib/transactions/query.js";
let {
value = $bindable(""),
caret = $bindable(0),
input = $bindable(),
suggestions,
onaccept,
onsubmit,
oncancel,
onfocus,
onblur,
id,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value" | "onsubmit"> & {
value?: string;
caret?: number;
input?: HTMLInputElement;
suggestions: Suggestion[];
onaccept: (item: Suggestion) => boolean | undefined;
onsubmit: () => void;
oncancel?: () => void;
id: string;
class?: string;
} = $props();
let cursor = $state(0);
let focused = $state(false);
let dismissed = $state(false);
const shown = $derived(focused && !dismissed && suggestions.length > 0);
const listId = $derived(`${id}-suggestions`);
$effect(() => {
if (suggestions) {
cursor = 0;
}
});
function sync() {
caret = input?.selectionStart ?? value.length;
}
function move(delta: number) {
const count = suggestions.length;
cursor = (cursor + delta + count) % count;
document
.getElementById(`${listId}-${cursor}`)
?.scrollIntoView({ block: "nearest" });
}
function accept(index: number): boolean {
const item = suggestions[index];
return item ? onaccept(item) !== false : false;
}
function onkeydown(event: KeyboardEvent) {
const ctrl = event.ctrlKey || event.metaKey;
const down = event.key === "ArrowDown" || (ctrl && event.key === "n");
const up = event.key === "ArrowUp" || (ctrl && event.key === "p");
if (shown && (down || up)) {
event.preventDefault();
move(down ? 1 : -1);
} else if (shown && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
accept(cursor);
} else if (event.key === "Enter") {
event.preventDefault();
if (!(shown && accept(cursor))) {
onsubmit();
}
} else if (event.key === "Escape") {
event.preventDefault();
if (shown) {
dismissed = true;
} else {
oncancel?.();
input?.blur();
}
}
}
</script>
<div class="relative {className}">
<input
{...rest}
aria-activedescendant={shown ? `${listId}-${cursor}` : undefined}
aria-autocomplete="list"
aria-controls={listId}
aria-expanded={shown}
autocomplete="off"
class="cladd-cut cladd-focusable-input h-8 w-full rounded-cladd-md px-2.5 font-mono text-[13px] text-cladd-fg placeholder:font-sans placeholder:text-cladd-fg-softer"
{id}
onblur={(event) => {
focused = false;
onblur?.(event);
}}
onclick={sync}
onfocus={(event) => {
focused = true;
sync();
onfocus?.(event);
}}
oninput={() => {
dismissed = false;
sync();
}}
{onkeydown}
onkeyup={sync}
role="combobox"
spellcheck="false"
bind:this={input}
bind:value
>
{#if shown}
<div
class="cladd-surface cladd-outline absolute top-full right-0 left-0 z-30 mt-1 max-h-72 overflow-y-auto rounded-cladd-md p-1 shadow-cladd-popover"
id={listId}
role="listbox"
>
{#each suggestions as item, index (item.id)}
<div
aria-selected={index === cursor}
class="flex h-7 cursor-pointer items-center gap-3 rounded-cladd-sm px-2 text-[13px] {index ===
cursor
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={`${listId}-${index}`}
onmousedown={(event) => {
event.preventDefault();
accept(index);
}}
role="option"
tabindex="-1"
>
<span class="min-w-0 flex-1 truncate font-mono">{item.label}</span>
{#if item.hint}
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
>{item.hint}</span
>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,595 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import Paperclip from "@lucide/svelte/icons/paperclip";
import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { buildRows, type Row } from "#lib/transactions/mass.js";
import {
accountUrl,
balanceAfter,
cloneUrl,
editUrl,
type Group,
groupType,
isCash,
type Signed,
showUrl,
signedAmount,
signedForeign,
sumByCurrency,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
import ActionBar, { type Mode } from "./ActionBar.svelte";
import TypeIcon from "./TypeIcon.svelte";
let {
groups,
context = {},
selectable = true,
loading = false,
id = "transactions",
empty = "No transactions.",
}: {
groups: Group[];
context?: { accountId?: string };
selectable?: boolean;
loading?: boolean;
id?: string;
empty?: string;
} = $props();
let collapsed = $state(new Set<string>());
let opened = $state(new Set<string>());
let bar = $state<ActionBar>();
let menuRows = $state<Row[]>([]);
let menuArmed = $state(false);
let barHeight = $state(0);
const CHECKBOX = "size-3.5 cursor-pointer align-middle accent-(--ff-brand)";
const accountId = $derived(context.accountId);
const rows = $derived(buildRows(groups, collapsed));
function toggled(set: Set<string>, key: string, on: boolean) {
const next = new Set(set);
if (on) {
next.add(key);
} else {
next.delete(key);
}
return next;
}
function open(row: Row | undefined) {
if (row) {
goto(showUrl(row.group.id));
}
}
function expand(row: Row | undefined) {
if (!row) {
return;
}
if (row.kind === "head" && collapsed.has(row.group.id)) {
collapsed = toggled(collapsed, row.group.id, false);
return;
}
opened = toggled(opened, row.key, true);
}
function collapse(row: Row | undefined) {
if (!row) {
return;
}
if (opened.has(row.key)) {
opened = toggled(opened, row.key, false);
return;
}
if (row.kind === "single") {
return;
}
collapsed = toggled(collapsed, row.group.id, true);
nav.move(rows.findIndex((r) => r.key === row.group.id));
}
function action(mode: Mode, label: string, keys: string): Binding {
return { group: "Selection", keys, label, run: () => bar?.start(mode) };
}
function bindings(): Binding[] {
const list: Binding[] = [
{
group: "List",
keys: "l",
label: "Expand row: splits, notes",
run: () => expand(nav.current),
},
{
group: "List",
keys: "h",
label: "Collapse row",
run: () => collapse(nav.current),
},
{
group: "List",
hidden: true,
keys: "ArrowRight",
label: "Expand row",
run: () => expand(nav.current),
},
{
group: "List",
hidden: true,
keys: "ArrowLeft",
label: "Collapse row",
run: () => collapse(nav.current),
},
{
group: "List",
keys: "c",
label: "Clone transaction",
run: () => nav.current && goto(cloneUrl(nav.current.group)),
},
];
if (selectable) {
list.push(
action("category", "Set category", "s c"),
action("budget", "Set budget", "s b"),
action("tag", "Add tag", "s t"),
action("untag", "Remove tag", "s T"),
{
group: "Selection",
keys: "d",
label: "Delete (press twice)",
run: () => bar?.remove(),
}
);
}
return list;
}
function createNav() {
return new ListNav<Row>({
edit: (row) => goto(editUrl(row.group.id)),
extra: bindings,
id,
items: () => rows,
key: (row) => row.key,
open,
selectable,
});
}
const nav = createNav();
nav.use();
function oncontextmenu(event: MouseEvent) {
const tr = (event.target as Element).closest("tr[data-index]");
menuArmed = false;
if (!tr) {
menuRows = [];
return;
}
const index = Number(tr.getAttribute("data-index"));
const row = rows[index];
nav.cursor = index;
menuRows = nav.isSelected(row) ? nav.selection : [row];
}
function oldUi(row: Row) {
location.assign(
oldUiUrl(new URL(showUrl(row.group.id), location.origin).href)
);
}
function common(splits: Split[], pick: (split: Split) => string | null) {
const values = new Set(splits.map(pick));
return values.size === 1 ? [...values][0] : null;
}
function rowClass(row: Row, index: number) {
const selected = nav.isSelected(row);
const cursor = nav.cursor === index;
let background = "hover:bg-row-hover";
if (selected) {
background = "bg-row-selected";
} else if (cursor) {
background = "bg-row-cursor";
}
const border = row.kind === "head" ? "border-cladd-outline border-t" : "";
const mark = cursor ? "shadow-[inset_2px_0_0_var(--ff-brand)]" : "";
return `${background} ${border} ${mark}`;
}
</script>
{#snippet day(
value: string | undefined
)}
<span class="md:hidden">{formatDate(value?.slice(0, 10), "short")}</span>
<span class="max-md:hidden">{formatDate(value?.slice(0, 10))}</span>
{/snippet}
{#snippet tagLinks(
tags: string[] | null | undefined
)}
{#each tags ?? [] as tag (tag)}
<a
class="text-cladd-fg-soft after:text-cladd-fg-softer hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
href={`/tags/show/${encodeURIComponent(tag)}`}
>{tag}</a
>
{/each}
{/snippet}
{#snippet money(
value: Signed
)}
<Amount
amount={value.amount}
code={value.code}
decimals={value.decimals}
symbol={value.symbol}
tone={value.tone}
/>
{/snippet}
{#snippet plain(
value: Money | null
)}
{#if value}
<span class="num text-cladd-fg-soft">{formatMoney(value)}</span>
{/if}
{/snippet}
{#snippet account(
accountIdValue: string | null | undefined,
name: string | null | undefined,
type: string | null | undefined
)}
{#if isCash(type)}
<span class="text-income">(cash)</span>
{:else if accountIdValue}
<a
class="block truncate hover:underline"
href={accountUrl(accountIdValue)}
title={name}
>{name}</a
>
{/if}
{/snippet}
{#snippet splitCells(
row: Row,
split: Split
)}
{@const amount = signedAmount(split, accountId)}
{@const foreign = signedForeign(split, accountId)}
{@const wide = opened.has(row.key)}
<td class="min-w-0 {row.kind === "split" ? "pl-6" : ""}">
<div class="flex min-w-0 items-center gap-1.5">
{#if split.reconciled}
<Check aria-label="Reconciled" class="size-3.5 shrink-0 text-income" />
{/if}
{#if split.has_attachments}
<Paperclip
aria-label="Has attachments"
class="size-3.5 shrink-0 text-cladd-fg-soft"
/>
{/if}
<a
class="min-w-0 hover:underline {wide ? "break-words" : "truncate"}"
href={showUrl(row.group.id)}
title={split.description}
>{split.description}</a
>
{#if split.tags?.length}
<span
class="ml-auto max-w-[45%] shrink-0 truncate text-[12px] 2xl:hidden"
>{@render tagLinks(split.tags)}</span
>
{/if}
</div>
{#if split.notes}
<div
class="flex min-w-0 items-start gap-0.5 text-[12px] text-cladd-fg-soft"
>
<button
aria-expanded={wide}
aria-label={wide ? "Collapse notes" : "Expand notes"}
class="-ml-0.5 grid size-4 shrink-0 place-items-center rounded hover:bg-cladd-surface-next hover:text-cladd-fg"
onclick={(event) => {
event.stopPropagation();
opened = toggled(opened, row.key, !wide);
}}
type="button"
>
<ChevronRight
class="size-3 transition-transform {wide ? "rotate-90" : ""}"
/>
</button>
<span
class="min-w-0 {wide
? "whitespace-pre-line break-words"
: "truncate"}"
>{split.notes}</span
>
</div>
{/if}
</td>
<td class="text-right">
<div class="flex flex-wrap justify-end gap-x-1">
{@render money(amount)}
{#if foreign}
<span class="num text-[12px] text-cladd-fg-soft"
>({formatMoney(foreign)})</span
>
{/if}
</div>
</td>
{#if accountId}
<td class="text-right">{@render plain(balanceAfter(split, accountId))}</td>
{/if}
<td class="num text-cladd-fg-soft">
{#if row.kind !== "split"}
{@render day(split.date)}
{/if}
</td>
<td class="max-md:hidden">
{@render account(split.source_id, split.source_name, split.source_type)}
</td>
<td class="max-md:hidden">
{@render account(
split.destination_id,
split.destination_name,
split.destination_type
)}
</td>
<td class="truncate max-lg:hidden">
{#if split.category_id}
<a class="hover:underline" href={`/categories/show/${split.category_id}`}
>{split.category_name}</a
>
{/if}
</td>
<td class="truncate max-xl:hidden">
{#if split.budget_id}
<a class="hover:underline" href={`/budgets/show/${split.budget_id}`}
>{split.budget_name}</a
>
{/if}
</td>
<td class="max-2xl:hidden {wide ? "" : "truncate"}">
{@render tagLinks(split.tags)}
</td>
{/snippet}
{#snippet headCells(
row: Row
)}
{@const splits = row.group.attributes.transactions}
{@const folded = collapsed.has(row.group.id)}
{@const source = common(splits, (s) => s.source_id)}
{@const destination = common(splits, (s) => s.destination_id)}
{@const sums = sumByCurrency(splits, accountId)}
<td class="min-w-0">
<div class="flex min-w-0 items-center gap-1">
<button
aria-expanded={!folded}
aria-label={folded ? "Show splits" : "Hide splits"}
class="-ml-1 grid size-5 shrink-0 place-items-center rounded hover:bg-cladd-surface-next"
onclick={(event) => {
event.stopPropagation();
collapsed = toggled(collapsed, row.group.id, !folded);
}}
type="button"
>
<ChevronRight
class="size-3.5 transition-transform {folded ? "" : "rotate-90"}"
/>
</button>
<a
class="truncate font-semibold hover:underline"
href={showUrl(row.group.id)}
>{row.group.attributes.group_title || splits[0]?.description}</a
>
<span class="shrink-0 text-[12px] text-cladd-fg-softer"
>{row.count}
splits</span
>
</div>
</td>
<td class="text-right">
<div class="flex flex-wrap justify-end gap-x-1 font-semibold">
{#each sums as sum, i (sum.code)}
{@render money(sum)}
{#if i < sums.length - 1}
,
{/if}
{/each}
</div>
</td>
{#if accountId}
<td></td>
{/if}
<td class="num text-cladd-fg-soft">
{@render day(splits[0]?.date)}
</td>
<td class="max-md:hidden">
{#if source}
{@render account(source, splits[0].source_name, splits[0].source_type)}
{/if}
</td>
<td class="max-md:hidden">
{#if destination}
{@render account(
destination,
splits[0].destination_name,
splits[0].destination_type
)}
{/if}
</td>
<td class="max-lg:hidden"></td>
<td class="max-xl:hidden"></td>
<td class="max-2xl:hidden"></td>
{/snippet}
<div>
<div
class="sticky top-12 z-20 bg-cladd-surface"
bind:clientHeight={barHeight}
>
<ActionBar
onclear={() => nav.clear()}
selected={nav.selected.size}
targets={nav.targets}
bind:this={bar}
/>
</div>
<ContextMenu>
<ContextMenuTrigger class="block outline-none" {oncontextmenu}>
<table class="w-full table-fixed border-collapse text-[13px]">
<thead
class="sticky z-10 bg-cladd-surface"
style:top={`calc(3rem + ${barHeight}px)`}
>
<tr
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:truncate [&>th]:px-2 [&>th]:font-medium"
>
{#if selectable}
<th class="w-8 max-sm:hidden">
<input
aria-label="Select all"
checked={rows.length > 0 && nav.selected.size === rows.length}
class={CHECKBOX}
onclick={(event) => {
event.currentTarget.blur();
nav.selectAll();
}}
type="checkbox"
>
</th>
{/if}
<th class="w-6 max-sm:hidden"><span class="sr-only">Type</span></th>
<th>Description</th>
<th class="w-28 text-right md:w-44">Amount</th>
{#if accountId}
<th class="w-32 text-right">Balance</th>
{/if}
<th class="w-16 md:w-[6.5rem]">Date</th>
<th class="w-34 max-md:hidden">Source account</th>
<th class="w-34 max-md:hidden">Destination account</th>
<th class="w-28 max-lg:hidden">Category</th>
<th class="w-24 max-xl:hidden">Budget</th>
<th class="w-28 max-2xl:hidden">Tags</th>
</tr>
</thead>
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
{#each rows as row, index (row.key)}
<tr
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
data-index={index}
id={nav.rowId(index)}
onclick={(event) => nav.click(index, event)}
ondblclick={() => open(row)}
>
{#if selectable}
<td class="max-sm:hidden">
<input
aria-label="Select row"
checked={nav.isSelected(row)}
class={CHECKBOX}
onclick={(event) => {
event.stopPropagation();
event.currentTarget.blur();
nav.toggle(row);
}}
type="checkbox"
>
</td>
{/if}
<td class="pt-1.5! max-sm:hidden">
{#if row.kind !== "split"}
<TypeIcon type={groupType(row.group)} />
{/if}
</td>
{#if row.split}
{@render splitCells(row, row.split)}
{:else}
{@render headCells(row)}
{/if}
</tr>
{/each}
</tbody>
</table>
{#if rows.length === 0}
<p class="px-3 py-6 text-center text-cladd-fg-soft">
{loading ? "Loading transactions..." : empty}
</p>
{/if}
</ContextMenuTrigger>
<ContextMenuContent class="w-60">
{#if menuRows.length > 0}
{@const first = menuRows[0]}
<ContextMenuItem onSelect={() => open(first)}>
Open <ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => goto(editUrl(first.group.id))}>
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => goto(cloneUrl(first.group))}>
Clone <ContextMenuShortcut>c</ContextMenuShortcut>
</ContextMenuItem>
{#if selectable}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => bar?.start("category", menuRows)}>
Set category... <ContextMenuShortcut>s c</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => bar?.start("untag", menuRows)}>
Remove tag... <ContextMenuShortcut>s T</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
if (menuArmed) {
bar?.remove(menuRows, true);
return;
}
event.preventDefault();
menuArmed = true;
}}
variant="destructive"
>
{menuArmed ? "Press again to delete" : "Delete"}
{menuRows.length > 1 ? `${menuRows.length} transactions` : ""}
<ContextMenuShortcut>d</ContextMenuShortcut>
</ContextMenuItem>
{/if}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => oldUi(first)}>
Open in old UI
</ContextMenuItem>
{:else}
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
{/if}
</ContextMenuContent>
</ContextMenu>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import Calculator from "@lucide/svelte/icons/calculator";
import Star from "@lucide/svelte/icons/star";
import { TYPE_LABEL } from "#lib/transactions/split.js";
let { type, class: className = "" }: { type: string; class?: string } =
$props();
const icons = {
deposit: ArrowRight,
reconciliation: Calculator,
transfer: ArrowLeftRight,
withdrawal: ArrowLeft,
};
const Icon = $derived(icons[type as keyof typeof icons] ?? Star);
</script>
<Icon
aria-label={TYPE_LABEL[type] ?? type}
class="size-3.5 shrink-0 text-cladd-fg-softer {className}"
role="img"
>
<title>{TYPE_LABEL[type] ?? type}</title>
</Icon>
+13 -2
View File
@@ -51,10 +51,21 @@ export function formatNumber(
return numberFormat(decimals, signed).format(toNumber(value));
}
function signOf(value: number, signed: boolean): string {
if (settings.anonymous || value === 0) {
return "";
}
if (value < 0) {
return "-";
}
return signed ? "+" : "";
}
export function formatMoney(money: Money, signed = false): string {
const number = formatNumber(money.amount, money.decimals ?? 2, signed);
const value = toNumber(money.amount);
const number = formatNumber(Math.abs(value), money.decimals ?? 2);
const symbol = money.symbol ?? money.code ?? "";
return symbol ? `${symbol}${number}` : number;
return `${signOf(value, signed)}${symbol}${number}`;
}
export function compactMoney(value: number, symbol = ""): string {
+162
View File
@@ -0,0 +1,162 @@
import { api } from "#lib/api/client.js";
import type { One, Split, TransactionGroup } from "#lib/api/types.js";
import { type Group, groupTitle, journalId } from "./split.js";
export type Patch = Record<string, unknown>;
export type Targets = Map<string, Set<string> | null>;
export interface Row {
count: number;
group: Group;
key: string;
kind: "single" | "head" | "split";
split?: Split;
}
export interface Outcome {
done: number;
errors: string[];
total: number;
}
export function buildRows(groups: Group[], collapsed: Set<string>): Row[] {
const rows: Row[] = [];
for (const group of groups) {
const splits = group.attributes.transactions;
const count = splits.length;
if (count === 1) {
rows.push({
count,
group,
key: group.id,
kind: "single",
split: splits[0],
});
continue;
}
rows.push({ count, group, key: group.id, kind: "head" });
if (collapsed.has(group.id)) {
continue;
}
for (const split of splits) {
rows.push({
count,
group,
key: `${group.id}:${journalId(split)}`,
kind: "split",
split,
});
}
}
return rows;
}
export function targetsOf(rows: Row[]): Targets {
const targets: Targets = new Map();
for (const row of rows) {
const set = targets.get(row.group.id) ?? new Set<string>();
const splits = row.split ? [row.split] : row.group.attributes.transactions;
for (const split of splits) {
set.add(journalId(split));
}
targets.set(row.group.id, set);
}
return targets;
}
export function splitsOf(rows: Row[]): Split[] {
return rows.flatMap((row) =>
row.split ? [row.split] : row.group.attributes.transactions
);
}
async function load(id: string) {
const fresh = await api<One<TransactionGroup>>(`/v1/transactions/${id}`);
return fresh.data;
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function each(
targets: Targets,
run: (id: string, journals: Set<string> | null) => Promise<void>,
progress: (outcome: Outcome) => void
): Promise<Outcome> {
const outcome: Outcome = { done: 0, errors: [], total: targets.size };
progress(outcome);
await Promise.all(
[...targets].map(async ([id, journals]) => {
try {
await run(id, journals);
outcome.done += 1;
} catch (error) {
outcome.errors.push(`#${id}: ${message(error)}`);
}
progress({ ...outcome });
})
);
return outcome;
}
export function updateGroups(
targets: Targets,
patch: (split: Split) => Patch | null,
progress: (outcome: Outcome) => void
): Promise<Outcome> {
return each(
targets,
async (id, journals) => {
const fresh = await load(id);
const splits = fresh.attributes.transactions;
let changed = false;
const transactions = splits.map((split) => {
const hit = journals === null || journals.has(journalId(split));
const change = hit ? patch(split) : null;
changed ||= change !== null;
return { transaction_journal_id: journalId(split), ...change };
});
if (!changed) {
return;
}
await api(`/v1/transactions/${id}`, {
body: {
transactions,
...(splits.length > 1 && { group_title: groupTitle(fresh) }),
},
method: "PUT",
});
},
progress
);
}
export function deleteTargets(
targets: Targets,
progress: (outcome: Outcome) => void
): Promise<Outcome> {
return each(
targets,
async (id, journals) => {
const fresh = await load(id);
const whole =
journals === null ||
fresh.attributes.transactions.every((split) =>
journals.has(journalId(split))
);
if (whole) {
await api(`/v1/transactions/${id}`, { method: "DELETE" });
return;
}
await [...journals].reduce(
(chain, journal) =>
chain.then(() =>
api(`/v1/transaction-journals/${journal}`, { method: "DELETE" })
),
Promise.resolve()
);
},
progress
);
}
+312
View File
@@ -0,0 +1,312 @@
import { api } from "#lib/api/client.js";
export interface Operator {
alias: boolean;
alias_for?: string;
name: string;
}
export type ValueKind =
| "account"
| "amount"
| "bill"
| "bool"
| "budget"
| "category"
| "currency"
| "date"
| "id"
| "none"
| "tag"
| "text"
| "type";
export interface Token {
end: number;
field: string | null;
negated: boolean;
start: number;
value: string;
}
export interface Suggestion {
hint?: string;
id: string;
insert: string;
label: string;
}
const BOOL_NAMES = new Set([
"reconciled",
"exists",
"source_is_cash",
"destination_is_cash",
"account_is_cash",
]);
const ACCOUNT_ALIASES = new Set(["source", "destination", "from", "to"]);
const BOOL_PREFIX = /^(has_|any_|no_)/;
const DATE_PART = /(^|_)(date|on|before|after|at)(_|$)/;
const AMOUNT_PART = /amount|balance|^less$|^more$/;
const ID_END = /(^|_)id$/;
const ACCOUNT_NAME = /(^|_)account_(is|contains|ends|starts)$/;
const NEEDS_QUOTES = /[\s"()]/;
const SEPARATOR = /[\s()]/;
const QUOTE = /"/g;
const TRAILING_QUOTE = /"$/;
const LEADING_SPACE = /^ /;
export const TYPES = [
"withdrawal",
"deposit",
"transfer",
"reconciliation",
"opening balance",
];
export function kindOf(name: string): ValueKind {
const checks: [boolean, ValueKind][] = [
[name === "user_action", "none"],
[BOOL_PREFIX.test(name) || BOOL_NAMES.has(name), "bool"],
[name === "transaction_type" || name === "type", "type"],
[DATE_PART.test(name), "date"],
[AMOUNT_PART.test(name), "amount"],
[ID_END.test(name) && !name.startsWith("external"), "id"],
[name.includes("currency"), "currency"],
[name.startsWith("category"), "category"],
[name.startsWith("budget"), "budget"],
[name.startsWith("tag"), "tag"],
[name.startsWith("bill") || name.startsWith("subscription"), "bill"],
[ACCOUNT_NAME.test(name) || ACCOUNT_ALIASES.has(name), "account"],
];
return checks.find(([hit]) => hit)?.[1] ?? "text";
}
interface OperatorConfig {
data: {
value: Record<string, { alias: boolean; alias_for?: string }>;
};
}
export const operatorsQuery = () => ({
queryFn: async (): Promise<Operator[]> => {
const config = await api<OperatorConfig>(
"/v1/configuration/search.operators"
);
return Object.entries(config.data.value)
.filter(([name]) => name !== "user_action")
.map(([name, item]) => ({
alias: item.alias,
alias_for: item.alias_for,
name,
}));
},
queryKey: ["search-operators"],
staleTime: Number.POSITIVE_INFINITY,
});
export function quote(value: string): string {
return value === "" || NEEDS_QUOTES.test(value)
? `"${value.replace(QUOTE, '\\"')}"`
: value;
}
function unquote(value: string): string {
return value.startsWith('"')
? value.slice(1).replace(TRAILING_QUOTE, "").replaceAll('\\"', '"')
: value;
}
export function tokenAt(text: string, caret: number): Token {
let start = 0;
let quoted = false;
for (let i = 0; i < caret; i += 1) {
const char = text[i];
if (char === '"' && text[i - 1] !== "\\") {
quoted = !quoted;
} else if (!quoted && SEPARATOR.test(char)) {
start = i + 1;
}
}
let end = caret;
while (end < text.length && (quoted || !SEPARATOR.test(text[end]))) {
if (text[end] === '"' && text[end - 1] !== "\\") {
quoted = !quoted;
}
end += 1;
}
const raw = text.slice(start, caret);
const negated = raw.startsWith("-");
const body = negated ? raw.slice(1) : raw;
const colon = body.indexOf(":");
return {
end,
field: colon < 0 ? null : body.slice(0, colon).toLowerCase(),
negated,
start,
value: colon < 0 ? body : unquote(body.slice(colon + 1)),
};
}
export function tokens(text: string): string[] {
const list: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
if (char === '"' && text[i - 1] !== "\\") {
quoted = !quoted;
}
if (!quoted && SEPARATOR.test(char)) {
if (current) {
list.push(current);
}
current = "";
} else {
current += char;
}
}
if (current) {
list.push(current);
}
return list;
}
function rank(operator: Operator, prefix: string): number {
const position = operator.name.indexOf(prefix);
if (position < 0) {
return -1;
}
return (position === 0 ? 0 : 2) + (operator.alias ? 1 : 0);
}
export function operatorSuggestions(
operators: Operator[],
token: Token,
limit = 8
): Suggestion[] {
const prefix = token.value.toLowerCase();
if (!prefix) {
return [];
}
const sign = token.negated ? "-" : "";
return operators
.map((operator, index) => ({
index,
operator,
score: rank(operator, prefix),
}))
.filter((entry) => entry.score >= 0)
.sort((a, b) => a.score - b.score || a.index - b.index)
.slice(0, limit)
.map(({ operator }) => ({
hint: operator.alias_for
? `same as ${operator.alias_for}`
: hintFor(kindOf(operator.name)),
id: `op:${operator.name}`,
insert: `${sign}${operator.name}:`,
label: `${sign}${operator.name}:`,
}));
}
function hintFor(kind: ValueKind): string {
const hints: Partial<Record<ValueKind, string>> = {
account: "account name",
amount: "amount",
bill: "subscription",
bool: "true or false",
budget: "budget",
category: "category",
currency: "currency",
date: "date",
id: "ids, comma separated",
tag: "tag",
type: "transaction type",
};
return hints[kind] ?? "text";
}
export function valueSuggestions(
token: Token,
values: string[],
hints: Record<string, string> = {},
limit = 8
): Suggestion[] {
const needle = token.value.toLowerCase();
const sign = token.negated ? "-" : "";
const starts: string[] = [];
const contains: string[] = [];
for (const value of new Set(values)) {
const lower = value.toLowerCase();
if (lower.startsWith(needle)) {
starts.push(value);
} else if (lower.includes(needle)) {
contains.push(value);
}
}
return [...starts, ...contains].slice(0, limit).map((value) => ({
hint: hints[value],
id: `value:${value}`,
insert: `${sign}${token.field}:${quote(value)} `,
label: value,
}));
}
export function replaceToken(
text: string,
token: Token,
insert: string
): { caret: number; text: string } {
const after = text
.slice(token.end)
.replace(LEADING_SPACE, insert.endsWith(" ") ? "" : " ");
const next = text.slice(0, token.start) + insert;
return { caret: next.length, text: next + (after.trim() ? after : "") };
}
function humanize(name: string): string {
return name.replaceAll("_", " ");
}
function describe(
field: string,
value: string,
negated: boolean,
known: Map<string, string>
): string {
const root = known.get(field);
if (known.size > 0 && !root) {
return `ignored ${field}:${value}`;
}
const name = humanize(root ?? field);
if (kindOf(root ?? field) === "bool") {
return (value === "true") === negated ? `not ${name}` : name;
}
return `${negated ? "not " : ""}${name} "${value}"`;
}
export function explain(text: string, operators: Operator[]): string[] {
const known = new Map(operators.map((o) => [o.name, o.alias_for ?? o.name]));
const words: string[] = [];
const excluded: string[] = [];
const parts: string[] = [];
for (const raw of tokens(text)) {
const negated = raw.startsWith("-");
const body = negated ? raw.slice(1) : raw;
const colon = body.indexOf(":");
if (colon < 0) {
(negated ? excluded : words).push(unquote(body));
} else {
const field = body.slice(0, colon).toLowerCase();
parts.push(
describe(field, unquote(body.slice(colon + 1)), negated, known)
);
}
}
if (excluded.length > 0) {
parts.unshift(`description without "${excluded.join(" ")}"`);
}
if (words.length > 0) {
parts.unshift(`description contains "${words.join(" ")}"`);
}
return parts;
}
+151
View File
@@ -0,0 +1,151 @@
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
import type { Money } from "#lib/format.js";
export type Group = Item<TransactionGroup>;
export type Tone = "income" | "expense" | "transfer" | "neutral";
export interface Signed extends Money {
amount: number;
tone: Tone;
}
export const TYPE_LABEL: Record<string, string> = {
deposit: "Deposit",
"liability credit": "Liability credit",
"opening balance": "Opening balance",
reconciliation: "Reconciliation",
transfer: "Transfer",
withdrawal: "Withdrawal",
};
export const LIST_TITLE: Record<string, string> = {
all: "All transactions",
deposit: "Revenue / income",
transfer: "Transfers",
withdrawal: "Expenses",
};
export function listType(param: string): string {
return param === "transfers" ? "transfer" : param;
}
export function listPath(type: string): string {
return `/transactions/${type === "transfer" ? "transfers" : type}`;
}
export function groupType(group: Group): string {
return group.attributes.transactions[0]?.type ?? "withdrawal";
}
export function groupTitle(group: Group): string {
const { group_title, transactions } = group.attributes;
return group_title || transactions[0]?.description || `#${group.id}`;
}
export function journalId(split: Split): string {
return split.transaction_journal_id ?? "";
}
export function createType(type: string): string {
return ["withdrawal", "deposit", "transfer"].includes(type)
? type
: "withdrawal";
}
export const showUrl = (id: string) => `/transactions/show/${id}`;
export const editUrl = (id: string) => `/transactions/edit/${id}`;
export const cloneUrl = (group: Group) =>
`/transactions/create/${createType(groupType(group))}?clone=${group.id}`;
function signFor(split: Split, accountId?: string): 1 | -1 {
switch (split.type as string) {
case "withdrawal":
return -1;
case "transfer":
return accountId && split.source_id === accountId ? -1 : 1;
case "opening balance":
return split.source_type === "Initial balance account" ? 1 : -1;
case "reconciliation":
return split.source_type === "Reconciliation account" ? 1 : -1;
case "liability credit":
return (split.source_type as string) === "Liability credit account"
? -1
: 1;
default:
return 1;
}
}
export function toneOf(split: Split, sign: 1 | -1): Tone {
if (split.type === "transfer") {
return "transfer";
}
return sign < 0 ? "expense" : "income";
}
export function signedAmount(split: Split, accountId?: string): Signed {
const sign = signFor(split, accountId);
return {
amount: sign * Number(split.amount),
code: split.currency_code,
decimals: split.currency_decimal_places,
symbol: split.currency_symbol,
tone: toneOf(split, sign),
};
}
export function signedForeign(split: Split, accountId?: string): Signed | null {
if (!(split.foreign_amount && split.foreign_currency_code)) {
return null;
}
const sign = signFor(split, accountId);
return {
amount: sign * Number(split.foreign_amount),
code: split.foreign_currency_code,
decimals: split.foreign_currency_decimal_places,
symbol: split.foreign_currency_symbol,
tone: toneOf(split, sign),
};
}
export function sumByCurrency(splits: Split[], accountId?: string): Signed[] {
const sums = new Map<string, Signed>();
for (const split of splits) {
const money = signedAmount(split, accountId);
const key = money.code ?? "";
const sum = sums.get(key);
if (sum) {
sum.amount += money.amount;
} else {
sums.set(key, { ...money });
}
}
return [...sums.values()];
}
export function balanceAfter(split: Split, accountId?: string): Money | null {
if (!accountId) {
return null;
}
const value =
split.source_id === accountId
? split.source_balance_after
: split.destination_balance_after;
if (value === null || value === undefined) {
return null;
}
return {
amount: value,
code: split.currency_code,
decimals: split.currency_decimal_places,
symbol: split.currency_symbol,
};
}
export function accountUrl(id: string | null | undefined): string | undefined {
return id ? `/accounts/show/${id}` : undefined;
}
export function isCash(type: string | null | undefined): boolean {
return type === "Cash account";
}