feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry

This commit is contained in:
hh committed 2026-10-16 03:25:02 +00:00
1 parent 135a2d9328
commit 7e19684cff
22 files changed
+4067 -10

No files matched your search

+402 -2
View File
@@ -1,9 +1,409 @@
<script lang="ts">
import X from "@lucide/svelte/icons/x";
import { onMount, tick } from "svelte";
import { ApiError, api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { One, Split, TransactionGroup } from "#lib/api/types.js";
import { Catalog } from "#lib/components/editor/catalog.svelte.js";
import { setDraft } from "#lib/components/editor/draft.js";
import {
type Form,
KINDS,
TYPE_LABEL,
toPayload,
validate,
} from "#lib/components/editor/model.js";
import {
complete,
counterpartyKinds,
type Marker,
markerValue,
parseQuick,
resolveQuick,
type Segment,
segmentAt,
sidesOf,
tagsOf,
} from "#lib/components/editor/quick.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
import { LIST } from "#lib/components/inputs/styles.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { prettyKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
const SUGGESTIONS = 6;
const HINT =
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
const catalog = new Catalog();
let input = $state<HTMLInputElement | null>(null);
let text = $state("");
let caret = $state(0);
let active = $state(-1);
let saving = $state(false);
let message = $state<{ tone: "ok" | "error"; text: string } | null>(null);
let saved = $state<{ id: string; text: string } | null>(null);
let recent = $state<Record<string, Split | null>>({});
const quick = $derived(parseQuick(text));
const own = $derived(sidesOf(quick.type).own);
const other = $derived(sidesOf(quick.type).other);
const segment = $derived(segmentAt(quick, text, caret));
function optionsFor(at: Segment | null): Option[] {
if (!at) {
return [];
}
const query = at.value.trim();
const lists: Record<Marker, () => Option[]> = {
"!": () => catalog.categoryOptions(query),
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
"#": () => catalog.tagOptions(query, tagsOf(quick)),
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
"~": () => [],
$: () => catalog.budgetOptions(query),
};
const found = lists[at.marker]().slice(0, SUGGESTIONS);
const done =
found.length === 1 &&
found[0].label.toLowerCase() === query.toLowerCase() &&
text.slice(0, caret).endsWith(" ");
return done ? [] : found;
}
const suggestions = $derived(optionsFor(segment));
const resolved = $derived(resolveQuick(quick, catalog, recent));
const errors = $derived(validate(resolved.form));
const allProblems = $derived([
...(quick.amount === null ? ["Start with an amount, like 12.50"] : []),
...resolved.problems,
...Object.entries(errors)
.filter(([key]) => !key.endsWith(".amount"))
.map(([, value]) => value[0]),
]);
$effect(() => {
const name = markerValue(quick, "@");
const found = name
? catalog.findAccount(counterpartyKinds(quick.type), name)
: undefined;
if (!found || found.id in recent || quick.type === "transfer") {
return;
}
const side = other;
catalog.lastFor(found.id, side, quick.type).then((past) => {
recent = { ...recent, [found.id]: past?.split ?? null };
});
});
onMount(() => {
input?.focus();
});
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,
});
}
function syncCaret() {
caret = input?.selectionStart ?? text.length;
}
async function accept(option: Option) {
if (!segment) {
return;
}
const next = complete(text, segment, option.label);
({ text } = next);
active = -1;
await tick();
input?.setSelectionRange(next.caret, next.caret);
({ caret } = next);
}
async function save() {
if (saving) {
return;
}
if (allProblems.length) {
message = { text: allProblems[0], tone: "error" };
return;
}
saving = true;
message = null;
const { form } = resolved;
const [split] = form.splits;
try {
const result = await api<One<TransactionGroup>>("/v1/transactions", {
body: toPayload(form, false, { applyRules: true, fireWebhooks: true }),
method: "POST",
});
const target = split[other].name || "(cash)";
saved = {
id: result.data.id,
text: `${split.description} ${money(form)} ${own === "source" ? "→" : "←"} ${target}`,
};
text = "";
caret = 0;
await invalidate();
} catch (error) {
message = {
text:
error instanceof ApiError
? (Object.values(error.errors)[0]?.[0] ?? error.message)
: String(error),
tone: "error",
};
} finally {
saving = false;
}
}
async function undo() {
if (!saved) {
return;
}
const { id, text: label } = saved;
saved = null;
await api(`/v1/transactions/${id}`, { method: "DELETE" });
message = { text: `Deleted ${label}`, tone: "ok" };
await invalidate();
input?.focus();
}
function openEditor() {
setDraft($state.snapshot(resolved.form) as Form);
ui.quickEntry = false;
goto(`/transactions/create/${quick.type}?draft=${Date.now()}`);
}
function handleKey(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
const count = suggestions.length;
if (event.key === "Enter" && mod) {
event.preventDefault();
openEditor();
} else if (event.key === "Enter") {
event.preventDefault();
if (active >= 0 && suggestions[active]) {
accept(suggestions[active]);
} else {
save();
}
} else if (event.key === "Tab" && count && !event.shiftKey) {
event.preventDefault();
accept(suggestions[Math.max(active, 0)]);
} else if (
count &&
(event.key === "ArrowDown" || (event.ctrlKey && event.key === "n"))
) {
event.preventDefault();
active = (active + 1) % count;
} else if (
count &&
(event.key === "ArrowUp" || (event.ctrlKey && event.key === "p"))
) {
event.preventDefault();
active = (active - 1 + count) % count;
} else if (event.key === "Escape" && count) {
event.preventDefault();
caret = -1;
}
}
const accountLabel = (value: Form["splits"][number]["source"]) => {
if (!value.name) {
return "(cash)";
}
return value.id ? value.name : `${value.name} (new)`;
};
const preview = $derived.by(() => {
const { form, fromPast } = resolved;
const [split] = form.splits;
const last = (yes: boolean) => (yes ? " (last time)" : "");
const parts: { text: string; tone?: string }[] = [
{
text: TYPE_LABEL[quick.type],
tone: `font-semibold ${tone(quick.type)}`,
},
{
text: quick.amount === null ? "no amount yet" : money(form),
tone: "num font-semibold text-cladd-fg",
},
{
text: `${accountLabel(split.source)} → ${accountLabel(split.destination)}`,
},
{ text: `"${split.description}"`, tone: "text-cladd-fg" },
];
if (split.category) {
const fresh = catalog.findCategory(split.category) ? "" : " (new)";
parts.push({
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
});
}
if (split.budget.id) {
parts.push({
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
});
}
if (split.tags.length) {
parts.push({ text: split.tags.map((tag) => `#${tag}`).join(" ") });
}
if (form.date !== today()) {
parts.push({ text: formatDate(form.date, "day") });
}
return parts;
});
const tone = (kind: string) =>
({
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
})[kind] ?? "";
</script>
<div
class="border-cladd-outline border-b bg-cladd-surface px-6 py-3 text-[13px]"
class="border-cladd-outline border-b bg-cladd-surface px-4 py-2.5 text-[13px] lg:px-6"
>
<button onclick={() => (ui.quickEntry = false)} type="button">Close</button>
<div class="mx-auto flex w-full max-w-[1920px] flex-col gap-1.5">
<div class="flex items-center gap-2">
<label
class="hidden shrink-0 font-semibold text-cladd-fg-soft sm:block"
for="quick-entry"
>Quick entry</label
>
<div class="relative min-w-0 flex-1">
<input
aria-autocomplete="list"
aria-controls="quick-entry-list"
aria-expanded={suggestions.length > 0}
aria-label="Quick entry"
autocomplete="off"
class="cladd-cut cladd-focusable-input h-10 w-full rounded-cladd-md px-3 font-mono text-[16px] text-cladd-fg placeholder:font-sans placeholder:text-cladd-fg-softer sm:h-9 sm:text-[14px]"
enterkeyhint="send"
id="quick-entry"
onclick={syncCaret}
oninput={() => {
syncCaret();
active = -1;
saved = null;
message = null;
}}
onkeydown={handleKey}
onkeyup={(event) => {
if (event.key.startsWith("Arrow") && !suggestions.length) {
syncCaret();
}
}}
placeholder="12.50 lunch @Costa Coffee !Eating out #work"
role="combobox"
spellcheck="false"
bind:this={input}
bind:value={text}
>
{#if suggestions.length}
<div class={LIST} id="quick-entry-list" role="listbox">
{#each suggestions as option, index (option.id)}
<div
aria-selected={index === active}
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 sm:py-1 {index ===
active
? "bg-row-cursor"
: "hover:bg-row-hover"}"
onmousedown={(event) => {
event.preventDefault();
accept(option);
}}
role="option"
tabindex="-1"
>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
{#if option.hint}
<span class="num text-[12px] text-cladd-fg-soft"
>{option.hint}</span
>
{/if}
{#if option.meta}
<span class="text-[11px] text-cladd-fg-softer"
>{option.meta}</span
>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-10 items-center gap-1.5 rounded-cladd-md px-3 font-semibold text-[12px] text-cladd-primary sm:h-9"
disabled={saving}
onclick={save}
type="button"
>
Save <span class="kbd hidden sm:inline-flex">↵</span>
</button>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable hidden h-9 items-center gap-1.5 rounded-cladd-md px-3 font-semibold text-[12px] text-cladd-fg sm:inline-flex"
onclick={openEditor}
type="button"
>
Editor <span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</button>
<button
aria-label="Close quick entry"
class="grid size-9 shrink-0 place-items-center rounded-cladd-md text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
onclick={() => (ui.quickEntry = false)}
type="button"
>
<X class="size-4" />
</button>
</div>
<div
class="flex min-h-5 flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:pl-[5.6rem]"
>
{#if saved}
<span class="text-income" role="status">Saved: {saved.text}</span>
<button class="text-brand hover:underline" onclick={undo} type="button">
Undo
</button>
<span class="text-cladd-fg-softer">·</span>
<a
class="text-brand hover:underline"
href="/transactions/show/{saved.id}"
>Open</a
>
{:else if message}
<span
class={message.tone === "error"
? "text-expense"
: "text-cladd-fg-soft"}
role="status"
>{message.text}</span
>
{:else if text.trim()}
{#each preview as part, index (index)}
{#if index}
<span class="text-cladd-fg-softer">·</span>
{/if}
<span class={part.tone ?? "text-cladd-fg-soft"}>{part.text}</span>
{/each}
{#if allProblems.length}
<span class="text-expense">· {allProblems[0]}</span>
{/if}
{:else}
<span class="text-cladd-fg-soft">{HINT}</span>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Account, Item } from "#lib/api/types.js";
import AutocompleteInput, {
type Option,
} from "#lib/components/inputs/AutocompleteInput.svelte";
import type { Catalog } from "./catalog.svelte.js";
import { type AccountRef, emptyAccount, toAccountRef } from "./model.js";
let {
value = $bindable(),
catalog,
kinds,
free = false,
id,
invalid = false,
disabled = false,
placeholder = "",
onpick,
}: {
value: AccountRef;
catalog: Catalog;
kinds: string[];
free?: boolean;
id: string;
invalid?: boolean;
disabled?: boolean;
placeholder?: string;
onpick?: (value: AccountRef) => void;
} = $props();
function setText(text: string) {
if (text !== value.name) {
value = { ...emptyAccount(), name: text };
}
}
function setSelected(option: Option | null) {
if (option?.data) {
value = toAccountRef(option.data as Item<Account>);
}
}
</script>
<AutocompleteInput
{disabled}
{free}
{id}
{invalid}
onpick={() => onpick?.(value)}
openOnFocus={!free}
{placeholder}
search={(query) => catalog.accountOptions(kinds, query)}
bind:selected={
() => (value.id ? { id: value.id, label: value.name } : null),
setSelected
}
bind:value={() => value.name, setText}
/>
@@ -0,0 +1,176 @@
<script lang="ts" module>
import { api } from "#lib/api/client.js";
import type { Attachment, One } from "#lib/api/types.js";
export async function upload(journalId: string, file: File) {
const created = await api<One<Attachment>>("/v1/attachments", {
body: {
attachable_id: journalId,
attachable_type: "TransactionJournal",
filename: file.name,
title: file.name,
},
method: "POST",
});
try {
await api(`/v1/attachments/${created.data.id}/upload`, {
body: new Blob([file], { type: "application/octet-stream" }),
method: "POST",
});
} catch (error) {
await api(`/v1/attachments/${created.data.id}`, {
method: "DELETE",
}).catch(() => undefined);
throw error;
}
}
</script>
<script lang="ts">
import Paperclip from "@lucide/svelte/icons/paperclip";
import X from "@lucide/svelte/icons/x";
import { createQuery } from "@tanstack/svelte-query";
import { all, invalidate } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { formatNumber } from "#lib/format.js";
let {
groupId = null,
journals = [],
queued = $bindable([]),
}: {
groupId?: string | null;
journals?: { id: string; label: string }[];
queued?: File[];
} = $props();
let input = $state<HTMLInputElement | null>(null);
let target = $state("");
let status = $state("");
let busy = $state(false);
const existing = createQuery(() => ({
enabled: Boolean(groupId),
queryFn: () => all<Attachment>(`/v1/transactions/${groupId}/attachments`),
queryKey: ["transaction-attachments", groupId],
}));
const journalLabel = (id: string | undefined) =>
journals.length > 1
? (journals.find((journal) => journal.id === id)?.label ?? "")
: "";
const size = (bytes: number | undefined) =>
bytes && bytes > 1_048_576
? `${formatNumber(bytes / 1_048_576, 1)} MB`
: `${formatNumber((bytes ?? 0) / 1024, 0)} KB`;
async function chosen(files: FileList | null) {
const list = [...(files ?? [])];
if (input) {
input.value = "";
}
if (!list.length) {
return;
}
if (!groupId) {
queued = [...queued, ...list];
return;
}
busy = true;
status = `Uploading ${list.length} file${list.length > 1 ? "s" : ""}`;
try {
await Promise.all(
list.map((file) => upload(target || journals[0].id, file))
);
status = `Uploaded ${list.map((file) => file.name).join(", ")}`;
await invalidate();
} catch (error) {
status = `Upload failed: ${error instanceof Error ? error.message : error}`;
} finally {
busy = false;
}
}
async function remove(id: string) {
await api(`/v1/attachments/${id}`, { method: "DELETE" });
status = "Attachment deleted";
await invalidate();
}
</script>
<Box title="Attachments">
<div class="flex flex-col gap-2">
{#each existing.data ?? [] as item (item.id)}
<div class="flex min-h-7 items-center gap-2 text-[13px]">
<Paperclip class="size-3.5 shrink-0 text-cladd-fg-soft" />
<a
class="min-w-0 flex-1 truncate text-brand hover:underline"
data-sveltekit-reload
href="/attachments/view/{item.id}"
rel="noopener"
target="_blank"
>{item.attributes.title || item.attributes.filename}</a
>
<span class="text-[12px] text-cladd-fg-soft"
>{journalLabel(item.attributes.attachable_id)}</span
>
<span class="num text-[12px] text-cladd-fg-soft"
>{size(item.attributes.size)}</span
>
<ConfirmButton onconfirm={() => remove(item.id)}>Delete</ConfirmButton>
</div>
{/each}
{#each queued as file, index (`${file.name}:${index}`)}
<div class="flex min-h-7 items-center gap-2 text-[13px]">
<Paperclip class="size-3.5 shrink-0 text-cladd-fg-soft" />
<span class="min-w-0 flex-1 truncate">{file.name}</span>
<span class="text-[12px] text-cladd-fg-soft">uploads after saving</span>
<button
aria-label="Remove {file.name}"
class="grid size-6 place-items-center rounded text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
onclick={() => (queued = queued.filter((_, i) => i !== index))}
type="button"
>
<X class="size-3.5" />
</button>
</div>
{/each}
<div class="flex flex-wrap items-center gap-2">
{#if journals.length > 1}
<select
aria-label="Attach to split"
class="cladd-cut h-cladd-md rounded-cladd-md px-2 text-[13px]"
bind:value={
() => target || journals[0]?.id,
(value) => (target = value)
}
>
{#each journals as journal (journal.id)}
<option value={journal.id}>{journal.label}</option>
{/each}
</select>
{/if}
<label
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-focusable-within inline-flex h-cladd-md cursor-pointer items-center gap-1.5 rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
>
<Paperclip class="size-3.5" />
{busy ? "Uploading" : "Add files"}
<input
class="sr-only"
disabled={busy}
multiple
onchange={(event) => chosen(event.currentTarget.files)}
type="file"
bind:this={input}
>
</label>
{#if status}
<span class="text-[12px] text-cladd-fg-soft" role="status"
>{status}</span
>
{/if}
</div>
</div>
</Box>
+695
View File
@@ -0,0 +1,695 @@
<script lang="ts">
import Copy from "@lucide/svelte/icons/copy";
import Plus from "@lucide/svelte/icons/plus";
import { onMount, tick, untrack } from "svelte";
import { ApiError, api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { formatMoney } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
import Attachments, { upload } from "./Attachments.svelte";
import { Catalog } from "./catalog.svelte.js";
import Links from "./Links.svelte";
import {
type Errors,
emptyForm,
emptySplit,
type Form,
fromGroup,
groupTitle,
KINDS,
mismatch,
OWN_SIDE,
SHARED,
sideOf,
switchType,
TX_TYPES,
type TxType,
TYPE_LABEL,
toAccountRef,
toPayload,
total,
validate,
} from "./model.js";
import SplitBox from "./SplitBox.svelte";
type After = "show" | "another" | "stay";
const OWN_KEY = "ffui.editor.own";
const AFTER_KEY = "ffui.editor.after";
const LEAVE_MS = 3000;
const LIST: Record<TxType, string> = {
deposit: "deposit",
transfer: "transfers",
withdrawal: "withdrawal",
};
const FIELDS: [string, string, string][] = [
["alt+d", "description", "description"],
["alt+f", "source", "source account"],
["alt+t", "destination", "destination account"],
["alt+w", "date", "date"],
["alt+m", "amount", "amount"],
["alt+k", "category", "category"],
["alt+b", "budget", "budget"],
["alt+g", "tags", "tags"],
["alt+r", "notes", "notes"],
];
const INLINE = new Set([
"description",
"source_id",
"source_name",
"destination_id",
"destination_name",
"date",
"amount",
"foreign_amount",
"foreign_currency_code",
"category_name",
"budget_id",
"bill_id",
"piggy_bank_id",
"tags",
"notes",
"internal_reference",
"external_url",
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
]);
let {
initial,
group = null,
prefill = {},
}: {
initial: Form;
group?: Item<TransactionGroup> | null;
prefill?: { source?: string | null; destination?: string | null };
} = $props();
const catalog = new Catalog();
const start = untrack(() => $state.snapshot(initial) as Form);
const editing = untrack(() => group !== null);
const originalType = start.type;
let form = $state<Form>(start);
let saved = $state(untrack(() => group));
let snapshot = JSON.stringify(start);
let errors = $state<Errors>({});
let status = $state<{
tone: "ok" | "error" | "info";
text: string;
href?: string;
extra?: string[];
} | null>(null);
let saving = $state(false);
let leaving = $state(false);
let queued = $state<File[]>([]);
let applyRules = $state(true);
let fireWebhooks = $state(true);
let after = $state<After>(
(localStorage.getItem(
`${AFTER_KEY}.${editing ? "edit" : "create"}`
) as After) ?? "show"
);
let root = $state<HTMLElement | null>(null);
const problem = $derived(mismatch(form));
const totals = $derived(total(form));
const journals = $derived(
(saved?.attributes.transactions ?? []).map((split, index, list) => ({
id: split.transaction_journal_id ?? "",
label:
list.length > 1
? `Split ${index + 1}: ${split.description}`
: split.description,
}))
);
const title = $derived(
editing
? `Edit transaction "${saved?.attributes.group_title || saved?.attributes.transactions[0]?.description}"`
: `Create new ${form.type}`
);
$effect(() => {
localStorage.setItem(`${AFTER_KEY}.${editing ? "edit" : "create"}`, after);
});
$effect(() => {
for (const split of form.splits) {
if (split.reconciled) {
continue;
}
const own = sideOf(form, split, OWN_SIDE[form.type]);
if (own.currency && own.currency.code !== split.currency?.code) {
split.currency = own.currency;
}
if (form.type === "transfer") {
const target = sideOf(form, split, "destination").currency;
const want =
target && target.code !== split.currency?.code ? target : null;
if ((want?.code ?? null) !== (split.foreignCurrency?.code ?? null)) {
split.foreignCurrency = want;
}
}
}
});
let prefilled = false;
$effect(() => {
if (!catalog.accounts.data || prefilled) {
return;
}
prefilled = true;
untrack(applyPrefill);
});
function applyPrefill() {
const [first] = form.splits;
for (const side of ["source", "destination"] as const) {
const id = prefill[side];
const found = id ? catalog.accountById(id) : undefined;
if (found) {
first[side] = toAccountRef(found);
}
}
const side = OWN_SIDE[form.type];
if (editing || first[side].id) {
return;
}
const remembered = catalog.accountById(localStorage.getItem(OWN_KEY) ?? "");
const fallback =
remembered &&
KINDS[form.type][side].includes(toAccountRef(remembered).kind)
? remembered
: catalog.defaultAccount();
if (fallback) {
first[side] = toAccountRef(fallback);
}
snapshot = JSON.stringify($state.snapshot(form));
}
onMount(() => {
tick().then(() => focusField("description", false));
});
function currentKey(): number {
const holder = document.activeElement?.closest("[data-split]");
return Number(holder?.getAttribute("data-split") ?? form.splits[0].key);
}
function focusField(field: string, select = true) {
const keys = [currentKey(), form.splits[0].key];
for (const key of keys) {
const element = document.getElementById(`f-${key}-${field}`);
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement
) {
element.focus();
if (select) {
element.select();
}
return;
}
}
}
function setType(type: TxType) {
switchType(form, type);
if (!editing) {
goto(`/transactions/create/${type}${location.search}`, {
replace: true,
shallow: true,
});
}
}
async function addSplit() {
const last = form.splits.at(-1);
const next = emptySplit();
if (last) {
next.currency = last.currency;
next.foreignCurrency =
form.type === "transfer" ? last.foreignCurrency : null;
const counterparty =
OWN_SIDE[form.type] === "source" ? "destination" : "source";
if (!SHARED[form.type][counterparty]) {
next[counterparty] = { ...last[counterparty] };
}
}
form.splits.push(next);
await tick();
document.getElementById(`f-${next.key}-description`)?.focus();
}
async function removeSplit(key = currentKey()) {
if (form.splits.length < 2) {
return;
}
const index = form.splits.findIndex((split) => split.key === key);
if (index === 0) {
for (const side of ["source", "destination"] as const) {
if (SHARED[form.type][side]) {
form.splits[1][side] = form.splits[0][side];
}
}
}
form.splits.splice(index, 1);
await tick();
const focus = form.splits[Math.max(0, index - 1)];
document.getElementById(`f-${focus.key}-description`)?.focus();
}
function money(amount: number, currency: Form["splits"][number]["currency"]) {
return formatMoney({
amount,
code: currency?.code,
decimals: currency?.decimals,
symbol: currency?.symbol,
});
}
async function report() {
const keys = Object.keys(errors);
const extra = keys
.filter((key) => {
const field = key.split(".").at(-1) ?? "";
return (
!(key.startsWith("transactions.") && INLINE.has(field)) &&
key !== "group_title"
);
})
.map((key) => errors[key][0]);
status = {
extra,
text:
keys.length === 1
? "One field needs attention"
: `${keys.length} fields need attention`,
tone: "error",
};
await tick();
root?.querySelector<HTMLElement>("[aria-invalid='true']")?.focus();
}
async function save() {
if (saving) {
return;
}
errors = validate(form);
if (Object.keys(errors).length) {
await report();
return;
}
saving = true;
status = { text: editing ? "Updating" : "Saving", tone: "info" };
try {
const body = toPayload(form, editing, { applyRules, fireWebhooks });
const result = await api<One<TransactionGroup>>(
editing ? `/v1/transactions/${saved?.id}` : "/v1/transactions",
{ body, method: editing ? "PUT" : "POST" }
);
await finish(result.data);
} catch (error) {
if (
error instanceof ApiError &&
error.status === 422 &&
Object.keys(error.errors).length
) {
({ errors } = error);
await report();
} else {
status = {
text: `Firefly did not save it: ${error instanceof Error ? error.message : error}`,
tone: "error",
};
}
} finally {
saving = false;
}
}
async function finish(result: Item<TransactionGroup>) {
const splits = result.attributes.transactions;
const failed: string[] = [];
const results = await Promise.allSettled(
queued.map((file) => upload(splits[0].transaction_journal_id ?? "", file))
);
for (const [index, outcome] of results.entries()) {
if (outcome.status === "rejected") {
failed.push(queued[index].name);
}
}
queued = [];
const own = form.splits[0][OWN_SIDE[form.type]];
if (own.id) {
localStorage.setItem(OWN_KEY, own.id);
}
await invalidate();
const label = `"${groupTitle(form) ?? form.splits[0].description}" ${totals.map((entry) => money(entry.amount, entry.currency)).join(" + ")}`;
const href = `/transactions/show/${result.id}`;
const uploadNote = failed.length
? ` Upload failed for ${failed.join(", ")}.`
: "";
if (splits[0].type !== form.type) {
saved = result;
form = fromGroup(result);
snapshot = JSON.stringify($state.snapshot(form));
status = {
href,
text: `Saved, but Firefly kept it as a ${splits[0].type}: these accounts don't fit a ${form.type}.`,
tone: "error",
};
return;
}
if (after === "show" && !failed.length) {
await goto(href);
return;
}
if (after === "another" && !editing) {
reset();
status = { href, text: `Saved ${label}.${uploadNote}`, tone: "ok" };
return;
}
if (!editing) {
await goto(`/transactions/edit/${result.id}`);
return;
}
saved = result;
form = fromGroup(result);
snapshot = JSON.stringify($state.snapshot(form));
errors = {};
status = { href, text: `Saved ${label}.${uploadNote}`, tone: "ok" };
}
function reset() {
const next = emptyForm(form.type);
const side = OWN_SIDE[form.type];
next.date = form.date;
next.splits[0][side] = form.splits[0][side];
next.splits[0].currency = form.splits[0].currency;
form = next;
errors = {};
snapshot = JSON.stringify($state.snapshot(form));
tick().then(() => focusField("description"));
}
async function remove() {
await api(`/v1/transactions/${saved?.id}`, { method: "DELETE" });
await goto(`/transactions/${LIST[originalType]}`);
await invalidate();
}
function cancel() {
if (ui.quickEntry || ui.mobileNav) {
ui.quickEntry = false;
ui.mobileNav = false;
return;
}
const dirty = JSON.stringify($state.snapshot(form)) !== snapshot;
if (dirty && !leaving) {
leaving = true;
status = {
text: "Press Esc again to discard your changes",
tone: "info",
};
setTimeout(() => (leaving = false), LEAVE_MS);
return;
}
if (history.length > 1) {
history.back();
} else {
goto(`/transactions/${LIST[form.type]}`);
}
}
useKeys(() => [
{
group: "Editor",
input: true,
keys: "mod+Enter",
label: "Save the transaction",
run: save,
},
{
group: "Editor",
input: true,
keys: "mod+shift+Enter",
label: "Add another split",
run: addSplit,
},
{
group: "Editor",
input: true,
keys: "mod+alt+Backspace",
label: "Remove the current split",
run: () => removeSplit(),
},
...TX_TYPES.map((type, index) => ({
group: "Editor",
input: true,
keys: `alt+${index + 1}`,
label: `Make it a ${type}`,
run: () => setType(type),
})),
...FIELDS.map(([keys, field, label]) => ({
group: "Editor",
input: true,
keys,
label: `Go to ${label}`,
run: () => focusField(field),
})),
{
group: "Editor",
input: true,
keys: "alt+i",
label: "Go to the split transaction title",
run: () => document.getElementById("group-title")?.focus(),
},
{
group: "Editor",
keys: "Escape",
label: "Cancel and go back",
run: cancel,
},
]);
const TONE = {
error: "text-expense",
info: "text-cladd-fg-soft",
ok: "text-income",
};
const TYPE_TONE: Record<TxType, string> = {
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
};
</script>
<PageHeader
crumbs={[
{ href: `/transactions/${LIST[originalType]}`, label: "Transactions" },
{ label: editing ? `Edit #${saved?.id}` : `Create new ${form.type}` },
]}
subtitle={title}
title="Transactions"
>
{#snippet actions()}
{#if editing && saved}
<a
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-[12px] text-cladd-fg"
href="/transactions/create/{form.type}?clone={saved.id}"
>
<Copy class="size-3.5" />
Clone
</a>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/if}
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4" bind:this={root}>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div
aria-label="Transaction type"
class="cladd-cut inline-flex h-9 items-center gap-0.5 rounded-full p-0.5 sm:h-8"
role="tablist"
>
{#each TX_TYPES as type, index (type)}
<button
aria-selected={form.type === type}
class="cladd-segment cladd-focusable inline-flex h-full items-center gap-2 rounded-full px-3 font-semibold text-[13px] {form.type ===
type
? TYPE_TONE[type]
: "text-cladd-fg-soft"}"
onclick={() => setType(type)}
role="tab"
tabindex={form.type === type ? 0 : -1}
type="button"
>
{TYPE_LABEL[type]}
<span class="kbd hidden lg:inline-flex"
>{prettyKeys(`alt+${index + 1}`)[0]}</span
>
</button>
{/each}
</div>
{#if editing && form.type !== originalType}
<p class="text-[13px] text-warn">
Saving converts this {originalType} into a {form.type}.
</p>
{/if}
{#if problem}
<p class="text-[13px] text-warn" role="alert">
{problem.from}
to {problem.to.toLowerCase()}
{problem.suggest
? `is a ${problem.suggest}, not a ${form.type}.`
: `does not work as a ${form.type}.`}
{#if problem.suggest}
{@const suggest = problem.suggest}
<button
class="ml-1 font-semibold text-brand underline-offset-2 hover:underline"
onclick={() => setType(suggest)}
type="button"
>
Make it a {suggest}
</button>
{/if}
</p>
{/if}
</div>
{#each form.splits as split, index (split.key)}
<div data-split={split.key}>
<SplitBox
{catalog}
create={!editing}
{errors}
{index}
onremove={() => removeSplit(split.key)}
bind:form
/>
</div>
{/each}
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-md items-center gap-1.5 rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
onclick={addSplit}
type="button"
>
<Plus class="size-3.5" />
Add another split
<span class="kbd hidden lg:inline-flex"
>{prettyKeys("mod+shift+Enter")[0]}</span
>
</button>
{#if form.splits.length > 1}
<span class="text-[13px] text-cladd-fg-soft">
Total of {form.splits.length} splits:
{#each totals as entry, index (entry.currency?.code ?? index)}
<span class="num ml-1 font-semibold text-cladd-fg"
>{money(entry.amount, entry.currency)}</span
>
{/each}
</span>
{/if}
</div>
{#if form.splits.length > 1}
<Box title="Split transaction">
<Field
error={errors.group_title?.[0]}
for="group-title"
keys="alt+i"
label="Description of the split transaction"
>
<input
class={INPUT}
id="group-title"
placeholder={form.splits[0].description}
bind:value={form.title}
>
</Field>
</Box>
{/if}
<div
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-9 items-center gap-2 rounded-cladd-md px-3.5 font-semibold text-[13px] text-cladd-primary disabled:opacity-60 sm:h-cladd-md"
disabled={saving}
onclick={save}
type="button"
>
{editing ? "Update transaction" : "Save transaction"}
<span class="kbd hidden sm:inline-flex"
>{prettyKeys("mod+Enter")[0]}</span
>
</button>
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
<span class="hidden sm:inline">After saving</span>
<select
aria-label="After saving"
class="cladd-cut h-8 rounded-cladd-md px-2 text-[13px] text-cladd-fg sm:h-cladd-md"
bind:value={after}
>
<option value="show">show the transaction</option>
{#if !editing}
<option value="another">create another one</option>
{/if}
<option value="stay">stay here to edit</option>
</select>
</label>
<label
class="hidden items-center gap-1.5 text-[13px] text-cladd-fg-soft sm:flex"
>
<input class="accent-brand" type="checkbox" bind:checked={applyRules}>
Apply rules
</label>
<label
class="hidden items-center gap-1.5 text-[13px] text-cladd-fg-soft sm:flex"
>
<input class="accent-brand" type="checkbox" bind:checked={fireWebhooks}>
Fire webhooks
</label>
{#if status}
<p
class="min-w-0 basis-full text-[13px] sm:basis-auto {TONE[status.tone]}"
role="status"
>
{status.text}
{#each status.extra ?? [] as line (line)}
<span class="block">{line}</span>
{/each}
{#if status.href}
<a
class="ml-1 text-brand underline-offset-2 hover:underline"
href={status.href}
>Open</a
>
{/if}
</p>
{/if}
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
</div>
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { ApiError, api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
LinkType,
One,
Page,
TransactionGroup,
TransactionLink,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import AutocompleteInput, {
type Option,
} from "#lib/components/inputs/AutocompleteInput.svelte";
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import { formatDate, formatMoney } from "#lib/format.js";
let { journals }: { journals: { id: string; label: string }[] } = $props();
interface Row {
id: string;
journal: string;
notes: string;
other: { description: string; group: string; money: string } | null;
phrase: string;
}
let from = $state("");
let kind = $state("");
let notes = $state("");
let search = $state("");
let picked = $state<Option | null>(null);
let status = $state("");
const ids = $derived(journals.map((journal) => journal.id));
const types = createQuery(() => ({
queryFn: () => all<LinkType>("/v1/link-types"),
queryKey: ["link-types"],
}));
const phrases = $derived(
[...(types.data ?? [])]
.sort(
(a, b) =>
Number(b.attributes.name === "Related") -
Number(a.attributes.name === "Related")
)
.flatMap((item) => [
{ label: item.attributes.outward, value: `${item.id}:outward` },
...(item.attributes.inward === item.attributes.outward
? []
: [{ label: item.attributes.inward, value: `${item.id}:inward` }]),
])
);
async function describe(journalId: string) {
const group = await api<One<TransactionGroup>>(
`/v1/transaction-journals/${journalId}`
).catch(() => null);
const split = group?.data.attributes.transactions.find(
(item) => item.transaction_journal_id === journalId
);
if (!(group && split)) {
return null;
}
return {
description: split.description,
group: group.data.id,
money: formatMoney({
amount: split.amount,
decimals: split.currency_decimal_places,
symbol: split.currency_symbol,
}),
};
}
const links = createQuery(() => ({
enabled: ids.length > 0 && types.isSuccess,
queryFn: async (): Promise<Row[]> => {
const pages = await Promise.all(
ids.map((id) =>
api<Page<TransactionLink>>(`/v1/transaction-journals/${id}/links`)
)
);
const byType = new Map(
(types.data ?? []).map((item) => [item.id, item.attributes])
);
const seen = new Set<string>();
const found: {
link: Page<TransactionLink>["data"][number];
own: string;
}[] = [];
for (const [index, page] of pages.entries()) {
for (const link of page.data) {
if (!seen.has(link.id)) {
seen.add(link.id);
found.push({ link, own: ids[index] });
}
}
}
const rows: Row[] = await Promise.all(
found.map(async ({ link, own }) => {
const outgoing = link.attributes.inward_id === own;
const type = byType.get(link.attributes.link_type_id);
return {
id: link.id,
journal: own,
notes: link.attributes.notes ?? "",
other: await describe(
outgoing ? link.attributes.outward_id : link.attributes.inward_id
),
phrase: (outgoing ? type?.outward : type?.inward) ?? "",
};
})
);
return rows;
},
queryKey: ["journal-links", ids],
}));
const searchTransactions = async (query: string): Promise<Option[]> => {
if (!query.trim()) {
return [];
}
const items = await api<
{
id: string;
description: string;
date?: string;
amount?: string;
currency_code?: string;
}[]
>("/v1/autocomplete/transactions-with-meta", {
query: { limit: 10, query },
});
return items
.filter((item) => !ids.includes(String(item.id)))
.map((item) => ({
hint: item.amount
? `${formatMoney({ amount: item.amount, code: item.currency_code })}`
: undefined,
id: String(item.id),
label: item.description,
meta: item.date ? formatDate(item.date, "medium") : undefined,
}));
};
async function add() {
const own = from || ids[0];
const [typeId, direction] = (kind || phrases[0]?.value || "").split(":");
if (!(picked && typeId)) {
status = "Pick a transaction to link";
return;
}
const outward = direction === "outward";
try {
await api("/v1/transaction-links", {
body: {
inward_id: outward ? own : picked.id,
link_type_id: typeId,
notes: notes.trim() || null,
outward_id: outward ? picked.id : own,
},
method: "POST",
});
status = `Linked to "${picked.label}"`;
picked = null;
search = "";
notes = "";
await invalidate();
} catch (error) {
status =
error instanceof ApiError
? (Object.values(error.errors)[0]?.[0] ?? error.message)
: String(error);
}
}
async function remove(id: string) {
await api(`/v1/transaction-links/${id}`, { method: "DELETE" });
status = "Link deleted";
await invalidate();
}
const journalLabel = (id: string) =>
journals.length > 1
? (journals.find((journal) => journal.id === id)?.label ?? "")
: "This transaction";
</script>
<Box title="Transaction links">
<div class="flex flex-col gap-3">
{#if links.data?.length}
<ul class="flex flex-col gap-1.5">
{#each links.data as row (row.id)}
<li class="flex min-h-7 flex-wrap items-center gap-x-2 text-[13px]">
<span class="text-cladd-fg-soft">{journalLabel(row.journal)}</span>
<span>{row.phrase}</span>
{#if row.other}
<a
class="min-w-0 truncate text-brand hover:underline"
href="/transactions/show/{row.other.group}"
>"{row.other.description}"</a
>
<span class="num text-cladd-fg-soft">({row.other.money})</span>
{/if}
{#if row.notes}
<span class="text-cladd-fg-soft">{row.notes}</span>
{/if}
<ConfirmButton class="ml-auto" onconfirm={() => remove(row.id)}
>Delete</ConfirmButton
>
</li>
{/each}
</ul>
{:else if links.isSuccess}
<p class="text-[13px] text-cladd-fg-soft">No links yet.</p>
{/if}
<div class="grid gap-2 sm:grid-cols-2">
{#if journals.length > 1}
<Field for="link-from" label="Split">
<select
class={INPUT}
id="link-from"
bind:value={() => from || ids[0], (value) => (from = value)}
>
{#each journals as journal (journal.id)}
<option value={journal.id}>{journal.label}</option>
{/each}
</select>
</Field>
{/if}
<Field for="link-type" label="This transaction">
<select
class={INPUT}
id="link-type"
bind:value={
() => kind || phrases[0]?.value,
(value) => (kind = value)
}
>
{#each phrases as phrase (phrase.value)}
<option value={phrase.value}>{phrase.label}</option>
{/each}
</select>
</Field>
<Field
class={journals.length > 1 ? "sm:col-span-2" : ""}
for="link-search"
label="Transaction"
>
<AutocompleteInput
async
id="link-search"
placeholder="Search by description or #id"
search={searchTransactions}
bind:selected={picked}
bind:value={search}
/>
</Field>
<Field class="sm:col-span-2" for="link-notes" label="Notes">
<input class={INPUT} id="link-notes" bind:value={notes}>
</Field>
</div>
<div class="flex items-center gap-2">
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-md items-center rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
onclick={add}
type="button"
>
Link transaction
</button>
{#if status}
<span class="text-[12px] text-cladd-fg-soft" role="status"
>{status}</span
>
{/if}
</div>
</div>
</Box>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import AutocompleteInput, {
type Option,
} from "#lib/components/inputs/AutocompleteInput.svelte";
import type { Ref } from "./model.js";
let {
value = $bindable(),
search,
id,
invalid = false,
placeholder = "",
}: {
value: Ref;
search: (query: string) => Option[];
id: string;
invalid?: boolean;
placeholder?: string;
} = $props();
</script>
<AutocompleteInput
{id}
{invalid}
openOnFocus
{placeholder}
{search}
bind:selected={
() => (value.id ? { id: value.id, label: value.name } : null),
(option) => {
if (option) {
value = { id: option.id, name: option.label };
}
}
}
bind:value={
() => value.name,
(text) => {
if (text !== value.name) {
value = { id: null, name: text };
}
}
}
/>
+466
View File
@@ -0,0 +1,466 @@
<script lang="ts">
import Trash from "@lucide/svelte/icons/trash-2";
import type { Split } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import AmountInput from "#lib/components/inputs/AmountInput.svelte";
import AutocompleteInput from "#lib/components/inputs/AutocompleteInput.svelte";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import AccountField from "./AccountField.svelte";
import type { Catalog } from "./catalog.svelte.js";
import {
type AccountRef,
CASH,
type Cur,
type Errors,
emptyAccount,
emptyRef,
type Form,
FREE_KIND,
fieldError,
hasForeign,
KINDS,
META_DATE_LABEL,
META_DATES,
SHARED,
type Side,
toAccountRef,
} from "./model.js";
import RefField from "./RefField.svelte";
let {
form = $bindable(),
index,
catalog,
errors,
create,
onremove,
}: {
form: Form;
index: number;
catalog: Catalog;
errors: Errors;
create: boolean;
onremove: () => void;
} = $props();
const split = $derived(form.splits[index]);
const count = $derived(form.splits.length);
const first = $derived(index === 0);
const id = (field: string) => `f-${split.key}-${field}`;
const err = (field: string) => fieldError(errors, index, field);
const keyHint = (keys: string) => (first ? keys : "");
const withdrawal = $derived(form.type === "withdrawal");
const counterparty = $derived<Side | null>(
{ deposit: "source", transfer: null, withdrawal: "destination" }[
form.type
] as Side | null
);
const optionalPref = $derived(
(session.prefs.transaction_journal_optional_fields ?? {}) as Record<
string,
boolean
>
);
let more = $state(false);
const showExtra = (key: string, value: string) =>
more || Boolean(value) || optionalPref[key] === true;
let filled = $state<{ text: string; undo: () => void } | null>(null);
function fill(past: Split, withCounterparty: boolean) {
const parts: string[] = [];
const undo: (() => void)[] = [];
const side = counterparty;
if (withCounterparty && side && past.type === form.type) {
const pastId = past[`${side}_id`];
const current = split[side];
const account = pastId ? catalog.accountById(pastId) : undefined;
if (
account &&
past[`${side}_type`] !== CASH &&
!(current.id || current.name.trim())
) {
const next = toAccountRef(account);
split[side] = next;
parts.push(next.name);
undo.push(() => {
if (split[side].id === next.id) {
split[side] = emptyAccount();
}
});
}
}
const category = past.category_name ?? "";
if (category && !split.category.trim()) {
split.category = category;
parts.push(`category ${category}`);
undo.push(() => {
if (split.category === category) {
split.category = "";
}
});
}
const budget = past.budget_id;
if (withdrawal && budget && !(split.budget.id || split.budget.name)) {
split.budget = { id: budget, name: past.budget_name ?? "" };
parts.push(`budget ${past.budget_name ?? ""}`);
undo.push(() => {
if (split.budget.id === budget) {
split.budget = emptyRef();
}
});
}
if (parts.length) {
filled = {
text: parts.join(", "),
undo: () => {
for (const step of undo) {
step();
}
filled = null;
},
};
}
}
async function pickedAccount(side: Side, value: AccountRef) {
if (side !== counterparty || !value.id) {
return;
}
const past = await catalog.lastFor(value.id, side, form.type);
if (past) {
fill(past.split, false);
}
}
async function pickedDescription(journalId: unknown) {
if (typeof journalId !== "string" || !journalId) {
return;
}
const past = await catalog.fromJournal(journalId);
if (past) {
fill(past.split, true);
}
}
const placeholder = (side: Side) =>
FREE_KIND[form.type][side] ? "(cash) or a new name" : "";
const sharedName = (side: Side) =>
form.splits[0][side].name || (FREE_KIND[form.type][side] ? "(cash)" : "");
const transferForeign = $derived(
form.type === "transfer" && hasForeign(split)
);
</script>
{#snippet account(
side: Side,
label: string,
keys: string
)}
<Field error={err(side)} for={id(side)} keys={keyHint(keys)} {label}>
{#if !first && SHARED[form.type][side]}
<p
class="flex h-9 items-center truncate text-[13px] text-cladd-fg-soft sm:h-cladd-md"
id={id(side)}
>
{sharedName(side) || "Same as split 1"}
<span class="ml-1 text-cladd-fg-softer">(same for all splits)</span>
</p>
{:else}
<AccountField
{catalog}
disabled={split.reconciled}
free={Boolean(FREE_KIND[form.type][side])}
id={id(side)}
invalid={Boolean(err(side))}
kinds={KINDS[form.type][side]}
onpick={(value) => pickedAccount(side, value)}
placeholder={placeholder(side)}
bind:value={split[side]}
/>
{/if}
</Field>
{/snippet}
<Box
body="p-3 sm:p-4"
title={count > 1
? `Split ${index + 1} / ${count}`
: "Transaction information"}
>
{#snippet tools()}
{#if count > 1}
<button
class="inline-flex h-7 items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-expense"
onclick={onremove}
type="button"
>
<Trash class="size-3.5" />
Remove split
<span class="kbd hidden lg:inline-flex"
>{prettyKeys("mod+alt+Backspace")[0]}</span
>
</button>
{/if}
{/snippet}
<div class="grid gap-x-6 gap-y-3 lg:grid-cols-2">
<div class="flex min-w-0 flex-col gap-3">
<Field
error={err("description")}
for={id("description")}
keys={keyHint("alt+d")}
label="Description"
>
<AutocompleteInput
async
free
id={id("description")}
invalid={Boolean(err("description"))}
onpick={(option) => pickedDescription(option.data)}
search={catalog.descriptionOptions}
bind:value={split.description}
/>
</Field>
{@render account("source", "Source account", "alt+f")}
{@render account("destination", "Destination account", "alt+t")}
{#if first}
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
<DateInput
id={id("date")}
invalid={Boolean(err("date"))}
placeholder="today, -1, 14.10"
bind:value={form.date}
/>
</Field>
{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field
error={err("amount")}
for={id("amount")}
keys={keyHint("alt+m")}
label="Amount"
>
<AmountInput
currency={split.currency?.code ?? ""}
decimals={split.currency?.decimals ?? 2}
disabled={split.reconciled}
id={id("amount")}
invalid={Boolean(err("amount"))}
placeholder="0.00 or 12.5+3"
bind:value={split.amount}
/>
</Field>
{#if transferForeign}
<Field
error={err("foreign_amount")}
for={id("foreign-amount")}
label="Amount in {form.splits[0].destination.name ||
split.foreignCurrency?.code}"
>
<AmountInput
currency={split.foreignCurrency?.code ?? ""}
decimals={split.foreignCurrency?.decimals ?? 2}
disabled={split.reconciled}
id={id("foreign-amount")}
invalid={Boolean(err("foreign_amount"))}
bind:value={split.foreignAmount}
/>
</Field>
{:else if form.type !== "transfer"}
<Field
error={err("foreign_amount") || err("foreign_currency_code")}
for={id("foreign-currency")}
label="Foreign amount"
>
<div class="flex gap-1.5">
<AutocompleteInput
class="w-20 shrink-0"
disabled={split.reconciled}
id={id("foreign-currency")}
invalid={Boolean(err("foreign_currency_code"))}
onpick={(option) =>
(split.foreignCurrency = option.data as Cur)}
placeholder="Cur."
search={catalog.currencyOptions}
bind:value={
() => split.foreignCurrency?.code ?? "",
(text) => {
if (!text.trim()) {
split.foreignCurrency = null;
}
}
}
/>
{#if hasForeign(split)}
<AmountInput
class="min-w-0 flex-1"
currency={split.foreignCurrency?.code ?? ""}
decimals={split.foreignCurrency?.decimals ?? 2}
disabled={split.reconciled}
id={id("foreign-amount")}
invalid={Boolean(err("foreign_amount"))}
bind:value={split.foreignAmount}
/>
{/if}
</div>
</Field>
{/if}
</div>
{#if split.reconciled}
<p class="text-[12px] text-warn">
This split is reconciled, so its amount and accounts are locked.
</p>
{/if}
</div>
<div
class="flex min-w-0 flex-col gap-3 border-cladd-outline lg:border-l lg:pl-6"
>
{#if withdrawal}
<Field
error={err("budget_id")}
for={id("budget")}
keys={keyHint("alt+b")}
label="Budget"
>
<RefField
id={id("budget")}
invalid={Boolean(err("budget_id"))}
placeholder="(no budget)"
search={catalog.budgetOptions}
bind:value={split.budget}
/>
</Field>
{/if}
<Field
error={err("category_name")}
for={id("category")}
keys={keyHint("alt+k")}
label="Category"
>
<AutocompleteInput
free
id={id("category")}
invalid={Boolean(err("category_name"))}
openOnFocus
search={catalog.categoryOptions}
bind:value={split.category}
/>
</Field>
{#if filled}
<p class="-mt-1.5 text-[12px] text-cladd-fg-soft">
Filled from last time: {filled.text}.
<button
class="text-brand underline-offset-2 hover:underline"
onclick={filled.undo}
type="button"
>
Undo
</button>
</p>
{/if}
{#if create}
<Field
error={err("piggy_bank_id")}
for={id("piggy")}
label="Piggy bank"
>
<RefField
id={id("piggy")}
invalid={Boolean(err("piggy_bank_id"))}
placeholder="(no piggy bank)"
search={catalog.piggyOptions}
bind:value={split.piggy}
/>
</Field>
{/if}
<Field
error={err("tags")}
for={id("tags")}
keys={keyHint("alt+g")}
label="Tags"
>
<TagsInput
id={id("tags")}
placeholder="Comma or Enter between tags"
search={(query, taken) => catalog.tagOptions(query, taken)}
bind:tags={split.tags}
/>
</Field>
{#if withdrawal}
<Field error={err("bill_id")} for={id("bill")} label="Subscription">
<RefField
id={id("bill")}
invalid={Boolean(err("bill_id"))}
placeholder="(no subscription)"
search={catalog.billOptions}
bind:value={split.bill}
/>
</Field>
{/if}
<Field
error={err("notes")}
for={id("notes")}
keys={keyHint("alt+r")}
label="Notes"
>
<textarea
class="{INPUT} h-auto max-h-80 min-h-16 py-1.5 leading-snug [field-sizing:content]"
id={id("notes")}
bind:value={split.notes}
></textarea>
</Field>
{#each META_DATES as key (key)}
{#if showExtra(key, split.dates[key])}
<Field error={err(key)} for={id(key)} label={META_DATE_LABEL[key]}>
<DateInput id={id(key)} bind:value={split.dates[key]} />
</Field>
{/if}
{/each}
{#if showExtra("internal_reference", split.internalReference)}
<Field
error={err("internal_reference")}
for={id("internal-reference")}
label="Internal reference"
>
<input
class={INPUT}
id={id("internal-reference")}
bind:value={split.internalReference}
>
</Field>
{/if}
{#if showExtra("external_url", split.externalUrl)}
<Field
error={err("external_url")}
for={id("external-url")}
label="External URL"
>
<input
class={INPUT}
id={id("external-url")}
type="url"
bind:value={split.externalUrl}
>
</Field>
{/if}
{#if !more}
<button
class="self-start text-[12px] text-brand underline-offset-2 hover:underline"
onclick={() => (more = true)}
type="button"
>
More fields: dates, internal reference, external URL
</button>
{/if}
</div>
</div>
</Box>
+249
View File
@@ -0,0 +1,249 @@
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { lists } from "#lib/api/lists.js";
import type {
Account,
Item,
One,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
import { formatMoney } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { session } from "#lib/session.svelte.js";
import { accountKind, type Cur, type Side, type TxType } from "./model.js";
const LIMIT = 40;
const OWN_KINDS = new Set(["Asset account", "Debt", "Loan", "Mortgage"]);
const lower = (value: string) => value.trim().toLowerCase();
function rank<T>(
items: T[],
query: string,
text: (item: T) => string,
recent: (item: T) => string
) {
if (query.trim()) {
return fuzzyFilter(items, query, text, LIMIT);
}
return [...items]
.sort((a, b) => recent(b).localeCompare(recent(a)))
.slice(0, LIMIT);
}
export interface Recent {
split: TransactionGroup["transactions"][number];
}
export class Catalog {
accounts = createQuery(() => lists.accounts("all"));
categories = createQuery(() => lists.categories());
budgets = createQuery(() => lists.budgets());
tags = createQuery(() => lists.tags());
bills = createQuery(() => lists.bills());
piggies = createQuery(() => lists.piggyBanks());
private readonly history = new Map<string, Promise<Recent | null>>();
accountsOf(kinds: string[]): Item<Account>[] {
return (this.accounts.data ?? []).filter(
(item) =>
item.attributes.active && kinds.includes(accountKind(item.attributes))
);
}
findAccount(kinds: string[], name: string): Item<Account> | undefined {
const wanted = lower(name);
return this.accountsOf(kinds).find(
(item) => lower(item.attributes.name) === wanted
);
}
accountById(id: string): Item<Account> | undefined {
return (this.accounts.data ?? []).find((item) => item.id === id);
}
accountOptions(kinds: string[], query: string): Option[] {
const items = rank(
this.accountsOf(kinds),
query,
(item) => item.attributes.name,
(item) => item.attributes.last_activity ?? ""
);
return items.map((item) => {
const kind = accountKind(item.attributes);
const own = OWN_KINDS.has(kind);
return {
data: item,
hint: own
? formatMoney({
amount: item.attributes.current_balance,
decimals: item.attributes.currency_decimal_places,
symbol: item.attributes.currency_symbol,
})
: undefined,
id: item.id,
label: item.attributes.name,
meta: own
? `${kind.replace(" account", "")} · ${item.attributes.currency_code}`
: kind.replace(" account", ""),
};
});
}
categoryNames(): string[] {
return (this.categories.data ?? []).map((item) => item.attributes.name);
}
categoryOptions = (query: string): Option[] =>
rank(
this.categories.data ?? [],
query,
(item) => item.attributes.name,
(item) => item.attributes.last_activity ?? ""
).map((item) => ({ id: item.id, label: item.attributes.name }));
budgetOptions = (query: string): Option[] =>
fuzzyFilter(
(this.budgets.data ?? []).filter((item) => item.attributes.active),
query,
(item) => item.attributes.name,
LIMIT
).map((item) => ({ id: item.id, label: item.attributes.name }));
billOptions = (query: string): Option[] =>
fuzzyFilter(
(this.bills.data ?? []).filter((item) => item.attributes.active),
query,
(item) => item.attributes.name ?? "",
LIMIT
).map((item) => ({
id: item.id,
label: item.attributes.name ?? "",
meta: item.attributes.object_group_title ?? undefined,
}));
piggyOptions = (query: string): Option[] =>
fuzzyFilter(
(this.piggies.data ?? []).filter(
(item) => item.attributes.active !== false
),
query,
(item) => item.attributes.name,
LIMIT
).map((item) => ({
id: item.id,
label: item.attributes.name,
meta: item.attributes.object_group_title ?? undefined,
}));
tagOptions(query: string, taken: string[] = []): Option[] {
const skip = new Set(taken.map(lower));
return fuzzyFilter(
(this.tags.data ?? []).filter(
(item) => !skip.has(lower(item.attributes.tag))
),
query,
(item) => item.attributes.tag,
LIMIT
).map((item) => ({ id: item.id, label: item.attributes.tag }));
}
findBudget(name: string) {
const wanted = lower(name);
return (this.budgets.data ?? []).find(
(item) => lower(item.attributes.name) === wanted
);
}
findCategory(name: string) {
const wanted = lower(name);
return (this.categories.data ?? []).find(
(item) => lower(item.attributes.name) === wanted
);
}
currencyOptions = (query: string): Option[] =>
fuzzyFilter(
session.currencies.filter((item) => item.attributes.enabled),
query,
(item) => `${item.attributes.code} ${item.attributes.name}`,
LIMIT
).map((item) => ({
data: {
code: item.attributes.code,
decimals: item.attributes.decimal_places ?? 2,
symbol: item.attributes.symbol,
} satisfies Cur,
id: item.id,
label: item.attributes.code,
meta: item.attributes.name,
}));
descriptionOptions = async (query: string): Promise<Option[]> => {
if (!query.trim()) {
return [];
}
const items = await api<
{ id: string; name: string; transaction_group_id?: string }[]
>("/v1/autocomplete/transactions", { query: { limit: 10, query } });
return items.map((item) => ({
data: item.id,
id: item.id,
label: item.name,
}));
};
defaultAccount(): Item<Account> | undefined {
const own = this.accountsOf(["Asset account"]);
for (const id of session.frontpageAccounts) {
const found = own.find((item) => item.id === id);
if (found) {
return found;
}
}
return (
own.find((item) => item.attributes.account_role === "defaultAsset") ??
own[0]
);
}
lastFor(accountId: string, side: Side, type: TxType): Promise<Recent | null> {
const key = `${accountId}:${side}`;
let found = this.history.get(key);
if (!found) {
found = api<Page<TransactionGroup>>(
`/v1/accounts/${accountId}/transactions`,
{
query: { limit: 5, types: type },
}
)
.then((page) => {
for (const group of page.data) {
const split = group.attributes.transactions.find(
(item) => item[`${side}_id`] === accountId
);
if (split) {
return { split };
}
}
return null;
})
.catch(() => null);
this.history.set(key, found);
}
return found;
}
async fromJournal(journalId: string): Promise<Recent | null> {
const group = await api<One<TransactionGroup>>(
`/v1/transaction-journals/${journalId}`
).catch(() => null);
const split = group?.data.attributes.transactions.find(
(item) => item.transaction_journal_id === journalId
);
return split ? { split } : null;
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Form } from "./model.js";
let pending: Form | null = null;
export function setDraft(form: Form) {
pending = form;
}
export function takeDraft(): Form | null {
const draft = pending;
pending = null;
return draft;
}
+599
View File
@@ -0,0 +1,599 @@
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
import { amountString, evalAmount } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
export type TxType = "withdrawal" | "deposit" | "transfer";
export const TX_TYPES: TxType[] = ["withdrawal", "deposit", "transfer"];
export const TYPE_LABEL: Record<TxType, string> = {
deposit: "Deposit",
transfer: "Transfer",
withdrawal: "Withdrawal",
};
export interface Cur {
code: string;
decimals: number;
symbol: string;
}
export interface Ref {
id: string | null;
name: string;
}
export interface AccountRef extends Ref {
currency: Cur | null;
kind: string;
}
export const META_DATES = [
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
] as const;
export type MetaDate = (typeof META_DATES)[number];
export const META_DATE_LABEL: Record<MetaDate, string> = {
book_date: "Book date",
due_date: "Due date",
interest_date: "Interest date",
invoice_date: "Invoice date",
payment_date: "Payment date",
process_date: "Processing date",
};
export interface SplitForm {
amount: string;
bill: Ref;
budget: Ref;
category: string;
currency: Cur | null;
dates: Record<MetaDate, string>;
description: string;
destination: AccountRef;
externalUrl: string;
foreignAmount: string;
foreignCurrency: Cur | null;
internalReference: string;
journalId: string | null;
key: number;
notes: string;
piggy: Ref;
reconciled: boolean;
source: AccountRef;
tags: string[];
}
export interface Form {
date: string;
splits: SplitForm[];
time: string;
title: string;
type: TxType;
}
export type Side = "source" | "destination";
const ASSET = "Asset account";
const EXPENSE = "Expense account";
const REVENUE = "Revenue account";
export const CASH = "Cash account";
const LIABILITIES = ["Debt", "Loan", "Mortgage"];
export const KINDS: Record<TxType, Record<Side, string[]>> = {
deposit: {
destination: [ASSET, ...LIABILITIES],
source: [REVENUE, ...LIABILITIES],
},
transfer: {
destination: [ASSET, ...LIABILITIES],
source: [ASSET, ...LIABILITIES],
},
withdrawal: {
destination: [EXPENSE, ...LIABILITIES],
source: [ASSET, ...LIABILITIES],
},
};
export const FREE_KIND: Record<TxType, Partial<Record<Side, string>>> = {
deposit: { source: REVENUE },
transfer: {},
withdrawal: { destination: EXPENSE },
};
export const SHARED: Record<TxType, Record<Side, boolean>> = {
deposit: { destination: true, source: false },
transfer: { destination: true, source: true },
withdrawal: { destination: false, source: true },
};
export const OWN_SIDE: Record<TxType, Side> = {
deposit: "destination",
transfer: "source",
withdrawal: "source",
};
const ROUTES: Record<TxType, Record<string, string[]>> = {
deposit: {
[CASH]: [ASSET, ...LIABILITIES],
[REVENUE]: [ASSET, ...LIABILITIES],
...Object.fromEntries(LIABILITIES.map((kind) => [kind, [ASSET]])),
},
transfer: {
[ASSET]: [ASSET],
...Object.fromEntries(LIABILITIES.map((kind) => [kind, LIABILITIES])),
},
withdrawal: {
[ASSET]: [EXPENSE, ...LIABILITIES, CASH],
...Object.fromEntries(LIABILITIES.map((kind) => [kind, [EXPENSE, CASH]])),
},
};
export const fits = (type: TxType, source: string, destination: string) =>
ROUTES[type][source]?.includes(destination) ?? false;
export const typeFor = (source: string, destination: string) =>
TX_TYPES.find((type) => fits(type, source, destination)) ?? null;
const SHORT_KIND: Record<string, string> = {
asset: ASSET,
cash: CASH,
expense: EXPENSE,
revenue: REVENUE,
};
export function accountKind(account: Account): string {
if (account.type === "liabilities" || account.type === "liability") {
const type = account.liability_type ?? "debt";
return type[0].toUpperCase() + type.slice(1);
}
return SHORT_KIND[account.type] ?? account.type;
}
export const accountCurrency = (account: Account): Cur | null =>
account.currency_code
? {
code: account.currency_code,
decimals: account.currency_decimal_places ?? 2,
symbol: account.currency_symbol ?? account.currency_code,
}
: null;
export const toAccountRef = (item: Item<Account>): AccountRef => ({
currency: accountCurrency(item.attributes),
id: item.id,
kind: accountKind(item.attributes),
name: item.attributes.name,
});
export function kindOf(type: TxType, side: Side, ref: AccountRef): string {
if (ref.id) {
return ref.kind;
}
const free = FREE_KIND[type][side];
if (!free) {
return "";
}
return ref.name.trim() ? free : CASH;
}
let lastKey = 0;
const nextKey = () => {
lastKey += 1;
return lastKey;
};
export const emptyRef = (): Ref => ({ id: null, name: "" });
export const emptyAccount = (): AccountRef => ({
currency: null,
id: null,
kind: "",
name: "",
});
export function emptySplit(): SplitForm {
return {
amount: "",
bill: emptyRef(),
budget: emptyRef(),
category: "",
currency: null,
dates: Object.fromEntries(META_DATES.map((key) => [key, ""])) as Record<
MetaDate,
string
>,
description: "",
destination: emptyAccount(),
externalUrl: "",
foreignAmount: "",
foreignCurrency: null,
internalReference: "",
journalId: null,
key: nextKey(),
notes: "",
piggy: emptyRef(),
reconciled: false,
source: emptyAccount(),
tags: [],
};
}
export const emptyForm = (type: TxType): Form => ({
date: today(),
splits: [emptySplit()],
time: "",
title: "",
type,
});
const cur = (
code: string | null | undefined,
symbol: string | null | undefined,
decimals: number | null | undefined
): Cur | null =>
code ? { code, decimals: decimals ?? 2, symbol: symbol ?? code } : null;
const toRef = (
id: string | null | undefined,
name: string | null | undefined
) => (id ? { id, name: name ?? "" } : emptyRef());
function accountFrom(
id: string | null,
name: string | null | undefined,
kind: string | undefined,
currency: Cur | null
): AccountRef {
if (kind === CASH || !id) {
return { ...emptyAccount(), kind: CASH };
}
return { currency, id, kind: kind ?? "", name: name ?? "" };
}
const fixed = (value: string | null | undefined, decimals: number) =>
value ? Number(value).toFixed(decimals) : "";
function fromSplit(split: Split, clone: boolean): SplitForm {
const currency = cur(
split.currency_code,
split.currency_symbol,
split.currency_decimal_places
);
const foreign = split.foreign_amount
? cur(
split.foreign_currency_code,
split.foreign_currency_symbol,
split.foreign_currency_decimal_places
)
: null;
const own = split.type === "deposit" ? "destination" : "source";
const otherCurrency =
split.type === "transfer" ? (foreign ?? currency) : null;
return {
...emptySplit(),
amount: fixed(split.amount, currency?.decimals ?? 2),
bill: toRef(split.bill_id, split.bill_name),
budget: toRef(split.budget_id, split.budget_name),
category: split.category_name ?? "",
currency,
dates: Object.fromEntries(
META_DATES.map((key) => [key, split[key]?.slice(0, 10) ?? ""])
) as Record<MetaDate, string>,
description: split.description,
destination: accountFrom(
split.destination_id,
split.destination_name,
split.destination_type,
own === "destination" ? currency : otherCurrency
),
externalUrl: split.external_url ?? "",
foreignAmount: foreign ? fixed(split.foreign_amount, foreign.decimals) : "",
foreignCurrency: foreign,
internalReference: split.internal_reference ?? "",
journalId: clone ? null : (split.transaction_journal_id ?? null),
notes: split.notes ?? "",
reconciled: clone ? false : Boolean(split.reconciled),
source: accountFrom(
split.source_id,
split.source_name,
split.source_type,
own === "source" ? currency : null
),
tags: split.tags ?? [],
};
}
export const isEditableType = (type: string): type is TxType =>
(TX_TYPES as string[]).includes(type);
export function fromGroup(group: Item<TransactionGroup>, clone = false): Form {
const splits = group.attributes.transactions;
const [first] = splits;
return {
date: clone ? today() : first.date.slice(0, 10),
splits: splits.map((split) => fromSplit(split, clone)),
time: clone ? "" : first.date.slice(11),
title: group.attributes.group_title ?? "",
type: isEditableType(first.type) ? first.type : "withdrawal",
};
}
function accountFields(side: Side, value: AccountRef) {
if (value.id) {
return { [`${side}_id`]: value.id };
}
const name = value.name.trim();
return name ? { [`${side}_name`]: name } : {};
}
export const sideOf = (form: Form, split: SplitForm, side: Side) =>
SHARED[form.type][side] ? form.splits[0][side] : split[side];
export function hasForeign(split: SplitForm): boolean {
return Boolean(
split.foreignCurrency && split.foreignCurrency.code !== split.currency?.code
);
}
function splitPayload(form: Form, split: SplitForm, update: boolean) {
const withdrawal = form.type === "withdrawal";
const body: Record<string, unknown> = {
bill_id: withdrawal ? split.bill.id : null,
budget_id: withdrawal ? split.budget.id : null,
category_name: split.category.trim() || null,
date: form.time ? `${form.date}T${form.time}` : form.date,
description: split.description.trim(),
external_url: split.externalUrl.trim() || null,
internal_reference: split.internalReference.trim() || null,
notes: split.notes.trim() || null,
tags: split.tags,
type: form.type,
};
for (const key of META_DATES) {
body[key] = split.dates[key] || null;
}
if (update && split.journalId) {
body.transaction_journal_id = split.journalId;
}
if (!(update || split.piggy.id === null)) {
body.piggy_bank_id = split.piggy.id;
}
if (split.reconciled) {
return body;
}
const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
return {
...body,
...accountFields("source", sideOf(form, split, "source")),
...accountFields("destination", sideOf(form, split, "destination")),
amount: amountString(split.amount, split.currency?.decimals ?? 2),
currency_code: split.currency?.code,
foreign_amount: foreign
? amountString(split.foreignAmount, split.foreignCurrency?.decimals ?? 2)
: null,
foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
};
}
const dropEmpty = (body: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(body).filter(
([, value]) => value !== null && value !== undefined
)
);
export interface SubmitOptions {
applyRules: boolean;
fireWebhooks: boolean;
}
export function groupTitle(form: Form): string | null {
if (form.splits.length < 2) {
return null;
}
return form.title.trim() || form.splits[0].description.trim();
}
export function toPayload(form: Form, update: boolean, options: SubmitOptions) {
const splits = form.splits.map((split) => splitPayload(form, split, update));
return {
apply_rules: options.applyRules,
fire_webhooks: options.fireWebhooks,
group_title: groupTitle(form),
...(update ? {} : { error_if_duplicate_hash: false }),
transactions: update ? splits : splits.map(dropEmpty),
};
}
export type Errors = Record<string, string[]>;
const ACCOUNT_FIELDS: Record<Side, string[]> = {
destination: ["destination_id", "destination_name"],
source: ["source_id", "source_name"],
};
export function fieldError(
errors: Errors,
index: number,
field: string
): string {
const keys = ACCOUNT_FIELDS[field as Side] ?? [field];
for (const key of keys) {
const found = errors[`transactions.${index}.${key}`];
if (found?.length) {
return found[0];
}
}
return "";
}
function accountProblem(
form: Form,
split: SplitForm,
side: Side
): string | null {
const value = sideOf(form, split, side);
if (value.id) {
return KINDS[form.type][side].includes(value.kind)
? null
: `A ${value.kind.toLowerCase()} can't be the ${side} of a ${form.type}`;
}
if (FREE_KIND[form.type][side]) {
return null;
}
return value.name.trim()
? `No ${KINDS[form.type][side][0].toLowerCase()} or liability called "${value.name.trim()}"`
: `Choose the ${side} account`;
}
export function mismatch(
form: Form
): { from: string; to: string; suggest: TxType | null } | null {
for (const split of form.splits) {
const source = kindOf(form.type, "source", sideOf(form, split, "source"));
const destination = kindOf(
form.type,
"destination",
sideOf(form, split, "destination")
);
if (source && destination && !fits(form.type, source, destination)) {
return {
from: source,
suggest: typeFor(source, destination),
to: destination,
};
}
}
return null;
}
type Problems = [string, string][];
function foreignProblems(form: Form, split: SplitForm): Problems {
const foreign = split.foreignAmount.trim();
const problems: Problems = [];
if (hasForeign(split) && !foreign) {
problems.push([
"foreign_amount",
form.type === "transfer"
? `Enter the amount in ${split.foreignCurrency?.code}`
: "Enter the foreign amount or clear the currency",
]);
}
if (foreign && !hasForeign(split)) {
problems.push(["foreign_currency_code", "Choose the foreign currency"]);
}
if (foreign && (evalAmount(foreign) ?? 0) <= 0) {
problems.push(["foreign_amount", "The amount must be a positive number"]);
}
return problems;
}
function moneyProblems(form: Form, split: SplitForm, index: number): Problems {
const problems: Problems = [];
const amount = evalAmount(split.amount);
if (amount === null || amount <= 0) {
problems.push([
"amount",
split.amount.trim()
? "The amount must be a positive number"
: "Enter an amount",
]);
}
for (const side of ["source", "destination"] as Side[]) {
const problem =
index === 0 || !SHARED[form.type][side]
? accountProblem(form, split, side)
: null;
if (problem) {
problems.push([`${side}_id`, problem]);
}
}
const source = sideOf(form, split, "source");
if (source.id && source.id === sideOf(form, split, "destination").id) {
problems.push([
"destination_id",
"Source and destination are the same account",
]);
}
problems.push(...foreignProblems(form, split));
return problems;
}
function linkProblems(form: Form, split: SplitForm): Problems {
const withdrawal = form.type === "withdrawal";
const loose = (value: Ref) => !value.id && value.name.trim() !== "";
const checks: [boolean, string, string][] = [
[withdrawal && loose(split.budget), "budget_id", "Pick an existing budget"],
[
withdrawal && loose(split.bill),
"bill_id",
"Pick an existing subscription",
],
[loose(split.piggy), "piggy_bank_id", "Pick an existing piggy bank"],
];
return checks
.filter(([failed]) => failed)
.map(([, field, message]) => [field, message]);
}
export function validate(form: Form): Errors {
const errors: Errors = {};
if (!form.date) {
errors["transactions.0.date"] = [
"Enter a date like today, -1, 14.10 or 2026-10-14",
];
}
form.splits.forEach((split, index) => {
const problems: Problems = [
...(index === 0 && !split.description.trim()
? ([["description", "Describe the transaction"]] as Problems)
: []),
...(split.reconciled ? [] : moneyProblems(form, split, index)),
...linkProblems(form, split),
];
for (const [field, message] of problems) {
errors[`transactions.${index}.${field}`] = [message];
}
});
return errors;
}
export function switchType(form: Form, to: TxType) {
const from = form.type;
if (from === to) {
return;
}
for (const split of form.splits) {
const own = split[OWN_SIDE[from]];
const other = split[OWN_SIDE[from] === "source" ? "destination" : "source"];
const ownSide = OWN_SIDE[to];
const otherSide: Side = ownSide === "source" ? "destination" : "source";
split[ownSide] = own;
split[otherSide] = adapt(other, to, otherSide);
}
form.type = to;
}
function adapt(value: AccountRef, type: TxType, side: Side): AccountRef {
if (value.id && KINDS[type][side].includes(value.kind)) {
return value;
}
if (FREE_KIND[type][side] && value.name.trim()) {
return { ...emptyAccount(), name: value.name };
}
return emptyAccount();
}
export function total(form: Form): { amount: number; currency: Cur | null }[] {
const sums = new Map<string, { amount: number; currency: Cur | null }>();
for (const split of form.splits) {
const code = split.currency?.code ?? "";
const entry = sums.get(code) ?? { amount: 0, currency: split.currency };
entry.amount += evalAmount(split.amount) ?? 0;
sums.set(code, entry);
}
return [...sums.values()];
}
+219
View File
@@ -0,0 +1,219 @@
import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
import type { Catalog } from "./catalog.svelte.js";
import {
emptyForm,
KINDS,
OWN_SIDE,
type Side,
type SplitForm,
type TxType,
TYPE_LABEL,
toAccountRef,
} from "./model.js";
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
const WORD = /\S+/g;
const NUMERIC = /^[\d(]/;
export interface Segment {
end: number;
marker: Marker;
start: number;
value: string;
}
export interface Quick {
amount: number | null;
amountText: string;
description: string;
segments: Segment[];
type: TxType;
}
export function parseQuick(input: string): Quick {
const segments: Segment[] = [];
const words: string[] = [];
let amount: number | null = null;
let amountText = "";
let deposit = false;
let current: Segment | null = null;
let first = true;
for (const match of input.matchAll(WORD)) {
let [word] = match;
let start = match.index;
if (first && word.startsWith("+")) {
deposit = true;
word = word.slice(1);
start += 1;
}
first = false;
if (!word) {
continue;
}
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 === ">");
let type: TxType = "withdrawal";
if (hasTarget) {
type = "transfer";
} else if (deposit) {
type = "deposit";
}
return { amount, amountText, description: words.join(" "), segments, type };
}
export const markerValue = (quick: Quick, marker: Marker) =>
quick.segments
.filter((segment) => segment.marker === marker)
.at(-1)
?.value.trim() ?? "";
export const tagsOf = (quick: Quick) =>
quick.segments
.filter((segment) => segment.marker === "#" && segment.value)
.map((segment) => segment.value);
export function segmentAt(
quick: Quick,
input: string,
caret: number
): Segment | null {
const last = quick.segments.at(-1);
for (const segment of quick.segments) {
const open =
segment === last && MULTI.has(segment.marker) && caret === input.length;
if (caret > segment.start && (caret <= segment.end || open)) {
return segment;
}
}
return null;
}
export function complete(
input: string,
segment: Segment,
label: string
): { caret: number; text: string } {
const before = input.slice(0, segment.start + 1) + label;
const rest = input.slice(segment.end).trimStart();
return { caret: before.length + 1, text: `${before} ${rest}` };
}
export const sidesOf = (type: TxType): { other: Side; own: Side } => {
const own = OWN_SIDE[type];
return { other: own === "source" ? "destination" : "source", own };
};
export const counterpartyKinds = (type: TxType) =>
type === "transfer"
? KINDS.transfer.destination
: KINDS[type][sidesOf(type).other];
function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
const { own, other } = sidesOf(quick.type);
const ownName = markerValue(quick, "^");
const ownAccount = ownName
? catalog.findAccount(KINDS[quick.type][own], ownName)
: catalog.defaultAccount();
if (ownAccount) {
split[own] = toAccountRef(ownAccount);
}
const otherName = markerValue(quick, quick.type === "transfer" ? ">" : "@");
const otherAccount = otherName
? catalog.findAccount(counterpartyKinds(quick.type), otherName)
: undefined;
split[other] = otherAccount
? toAccountRef(otherAccount)
: { ...split[other], name: otherName };
return ownAccount || !ownName
? []
: [`No asset account or liability called "${ownName}"`];
}
function resolveLinks(
quick: Quick,
catalog: Catalog,
split: SplitForm,
past: Split | null | undefined
) {
const categoryName = markerValue(quick, "!");
const budgetName = markerValue(quick, "$");
const learn = quick.type !== "transfer" && past;
split.category = categoryName || (learn ? (past.category_name ?? "") : "");
const budget = budgetName ? catalog.findBudget(budgetName) : undefined;
if (budget) {
split.budget = { id: budget.id, name: budget.attributes.name };
} else if (!budgetName && learn && past.budget_id) {
split.budget = { id: past.budget_id, name: past.budget_name ?? "" };
}
return {
fromPast: {
budget: !budgetName && Boolean(split.budget.id),
category: !categoryName && Boolean(split.category),
},
problems: budgetName && !budget ? [`No budget called "${budgetName}"`] : [],
};
}
export function resolveQuick(
quick: Quick,
catalog: Catalog,
recent: Record<string, Split | null>
) {
const form = emptyForm(quick.type);
const [split] = form.splits;
const { own, other } = sidesOf(quick.type);
const problems = resolveAccounts(quick, catalog, split);
const counterparty = split[other].id;
const links = resolveLinks(
quick,
catalog,
split,
counterparty ? recent[counterparty] : null
);
problems.push(...links.problems);
split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
split.description =
quick.description ||
split[other].name ||
split.category ||
TYPE_LABEL[quick.type];
const dateText = markerValue(quick, "~");
const date = dateText ? parseDate(dateText) : today();
if (date) {
form.date = date;
} else {
problems.push(`"${dateText}" is not a date`);
}
return { form, fromPast: links.fromPast, problems };
}
@@ -0,0 +1,63 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { formatNumber } from "#lib/format.js";
import { evalAmount, isExpression } from "./parse.js";
import { INPUT } from "./styles.js";
let {
value = $bindable(""),
ref = $bindable(null),
currency = "",
decimals = 2,
invalid = false,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
currency?: string;
decimals?: number;
invalid?: boolean;
} = $props();
const result = $derived(evalAmount(value));
const preview = $derived(
isExpression(value) && result !== null
? `= ${formatNumber(result, decimals)}`
: ""
);
function normalise() {
if (result !== null && result > 0) {
value = result.toFixed(decimals);
}
}
</script>
<div class="relative {className}">
<input
bind:this={ref}
{...rest}
aria-invalid={invalid ||
(value.trim() !== "" && result === null) ||
undefined}
autocomplete="off"
class="{INPUT} num text-right {currency ? "pr-12" : ""}"
inputmode="decimal"
onblur={normalise}
spellcheck="false"
bind:value
>
{#if currency}
<span
class="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-[12px] text-cladd-fg-soft"
>{currency}</span
>
{/if}
{#if preview}
<span
class="num pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[12px] text-cladd-fg-soft"
>{preview}</span
>
{/if}
</div>
@@ -0,0 +1,253 @@
<script lang="ts" module>
export interface Option {
data?: unknown;
hint?: string;
id: string;
label: string;
meta?: string;
}
</script>
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import { INPUT, LIST } from "./styles.js";
const DEBOUNCE_MS = 150;
const STEPS: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 };
const CTRL_STEPS: Record<string, number> = { n: 1, p: -1 };
let {
value = $bindable(""),
selected = $bindable(null),
ref = $bindable(null),
search,
onpick,
free = false,
async = false,
openOnFocus = false,
invalid = false,
bare = false,
class: className = "",
inputClass = "",
onkeydown: onkey,
onblur: onleave,
onfocus: onenter,
onenter: onsubmit,
...rest
}: Omit<HTMLInputAttributes, "value" | "onkeydown" | "onblur" | "onfocus"> & {
value?: string;
selected?: Option | null;
ref?: HTMLInputElement | null;
search: (query: string) => Option[] | Promise<Option[]>;
onpick?: (option: Option) => void;
free?: boolean;
async?: boolean;
openOnFocus?: boolean;
invalid?: boolean;
bare?: boolean;
inputClass?: string;
onkeydown?: (event: KeyboardEvent) => void;
onblur?: (event: FocusEvent) => void;
onfocus?: (event: FocusEvent) => void;
onenter?: (event: KeyboardEvent) => void;
} = $props();
const uid = $props.id();
let open = $state(false);
let items = $state<Option[]>([]);
let active = $state(-1);
let timer: ReturnType<typeof setTimeout> | undefined;
let ticket = 0;
const query = $derived(value.trim());
const exact = $derived(
items.find((item) => item.label.toLowerCase() === query.toLowerCase())
);
const shown = $derived(
free && query && !exact
? [...items, { hint: "new", id: "", label: query }]
: items
);
async function load(text: string, highlight: boolean) {
ticket += 1;
const mine = ticket;
const result = await search(text);
if (mine !== ticket || document.activeElement !== ref) {
return;
}
items = result;
open = true;
active = highlight && text.trim() ? 0 : -1;
}
function refresh(highlight: boolean) {
clearTimeout(timer);
if (async) {
timer = setTimeout(() => load(value, highlight), DEBOUNCE_MS);
} else {
load(value, highlight);
}
}
function close() {
clearTimeout(timer);
ticket += 1;
open = false;
active = -1;
}
function pick(option: Option) {
value = option.label;
selected = option.id ? option : null;
close();
onpick?.(option);
}
function move(step: number) {
if (!open) {
refresh(true);
return;
}
const count = shown.length;
if (count) {
active =
(active + step + count + (active < 0 && step < 0 ? 1 : 0)) % count;
}
}
function stepOf(event: KeyboardEvent): number {
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey;
return STEPS[event.key] ?? (ctrl ? CTRL_STEPS[event.key] : 0) ?? 0;
}
function handleKey(event: KeyboardEvent) {
onkey?.(event);
if (event.defaultPrevented) {
return;
}
const step = stepOf(event);
if (step) {
event.preventDefault();
move(step);
return;
}
if (open && event.key === "Escape") {
event.preventDefault();
close();
return;
}
const plain = !(
event.shiftKey ||
event.ctrlKey ||
event.metaKey ||
event.altKey
);
const choice = open && plain ? shown[active] : undefined;
if (choice && (event.key === "Enter" || event.key === "Tab")) {
if (event.key === "Enter") {
event.preventDefault();
}
pick(choice);
return;
}
if (event.key === "Enter" && plain) {
onsubmit?.(event);
}
}
function handleInput({
currentTarget,
}: Event & { currentTarget: HTMLInputElement }) {
({ value } = currentTarget);
if (selected && selected.label !== value) {
selected = null;
}
refresh(true);
}
function handleBlur(event: FocusEvent) {
if (!selected && exact) {
value = exact.label;
selected = exact;
onpick?.(exact);
}
close();
onleave?.(event);
}
function handleFocus(event: FocusEvent) {
if (openOnFocus) {
refresh(false);
}
onenter?.(event);
}
$effect(() => {
if (open && active >= 0) {
document
.getElementById(`${uid}-${active}`)
?.scrollIntoView({ block: "nearest" });
}
});
</script>
<div class="{bare ? "" : "relative"} {className}">
<input
bind:this={ref}
{...rest}
aria-activedescendant={open && active >= 0 ? `${uid}-${active}` : undefined}
aria-autocomplete="list"
aria-controls="{uid}-list"
aria-expanded={open && shown.length > 0}
aria-invalid={invalid || undefined}
autocomplete="off"
class={inputClass || (bare ? "" : INPUT)}
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
onkeydown={handleKey}
role="combobox"
spellcheck="false"
{value}
>
{#if open && shown.length > 0}
<div class={LIST} id="{uid}-list" role="listbox">
{#each shown as option, index (`${option.id}:${option.label}`)}
<div
aria-selected={index === active}
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 text-[13px] sm:py-1 {index ===
active
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id="{uid}-{index}"
onmousedown={(event) => {
event.preventDefault();
pick(option);
}}
role="option"
tabindex="-1"
>
<span class="min-w-0 flex-1 truncate">
{#if option.hint === "new"}
<span class="text-cladd-fg-soft">Create</span>
"{option.label}"
{:else}
{option.label}
{/if}
</span>
{#if option.hint && option.hint !== "new"}
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
>{option.hint}</span
>
{/if}
{#if option.meta}
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
>{option.meta}</span
>
{/if}
</div>
{/each}
</div>
{/if}
</div>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import Calendar from "@lucide/svelte/icons/calendar";
import { untrack } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";
import { addDays, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { parseDate } from "./parse.js";
import { INPUT } from "./styles.js";
let {
value = $bindable(""),
ref = $bindable(null),
invalid = false,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
invalid?: boolean;
} = $props();
let text = $state(value);
let picker = $state<HTMLInputElement | null>(null);
const parsed = $derived(parseDate(text, today()));
const shown = $derived(parsed ?? value);
const label = $derived(
shown
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
: ""
);
$effect(() => {
const next = value;
untrack(() => {
if (next && next !== parseDate(text)) {
text = next;
}
});
});
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
text = event.currentTarget.value;
const next = parseDate(text);
if (next) {
value = next;
}
}
function handleBlur() {
if (parsed) {
text = parsed;
value = parsed;
}
}
function handleKey(event: KeyboardEvent) {
if (
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
!(event.altKey || event.ctrlKey || event.metaKey)
) {
event.preventDefault();
const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
value = addDays(parsed ?? value ?? today(), step);
text = value;
}
}
function openPicker() {
picker?.showPicker?.();
}
</script>
<div class="relative {className}">
<input
bind:this={ref}
{...rest}
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
autocomplete="off"
class="{INPUT} num pr-28"
onblur={handleBlur}
oninput={handleInput}
onkeydown={handleKey}
spellcheck="false"
value={text}
>
<span
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
>{label}</span
>
<button
aria-label="Pick a date"
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
onclick={openPicker}
tabindex="-1"
type="button"
>
<Calendar class="size-3.5" />
</button>
<input
aria-hidden="true"
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
onchange={({ currentTarget }) => {
({ value } = currentTarget);
text = value;
}}
tabindex="-1"
type="date"
{value}
bind:this={picker}
>
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
let {
label,
for: target,
error: message = "",
keys = "",
note,
children,
class: className = "",
}: {
label: string;
for?: string;
error?: string;
keys?: string;
note?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<div class="flex min-w-0 flex-col gap-1 {className}">
<div class="flex items-baseline justify-between gap-2">
<label class="text-[12px] text-cladd-fg-soft" for={target}>{label}</label>
{#if keys}
<span class="kbd hidden lg:inline-flex"
>{prettyKeys(keys).join(" ")}</span
>
{/if}
</div>
{@render children()}
{#if message}
<p class="text-[12px] text-expense" role="alert">{message}</p>
{/if}
{@render note?.()}
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import X from "@lucide/svelte/icons/x";
import AutocompleteInput, { type Option } from "./AutocompleteInput.svelte";
import { WELL } from "./styles.js";
let {
tags = $bindable([]),
ref = $bindable(null),
search,
id,
placeholder = "",
invalid = false,
}: {
tags?: string[];
ref?: HTMLInputElement | null;
search: (query: string, taken: string[]) => Option[];
id?: string;
placeholder?: string;
invalid?: boolean;
} = $props();
let text = $state("");
function add(raw: string) {
const names = raw
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const known = new Set(tags.map((tag) => tag.toLowerCase()));
const fresh = names.filter((name) => !known.has(name.toLowerCase()));
if (fresh.length) {
tags = [...tags, ...fresh];
}
text = "";
}
function remove(index: number) {
tags = tags.filter((_, i) => i !== index);
ref?.focus();
}
function handleKey(event: KeyboardEvent) {
const plain = !(event.ctrlKey || event.metaKey || event.altKey);
if (event.key === "," && plain) {
event.preventDefault();
add(text);
return;
}
if (event.key === "Backspace" && text === "" && tags.length && plain) {
event.preventDefault();
tags = tags.slice(0, -1);
}
}
function handleEnter(event: KeyboardEvent) {
if (text.trim()) {
event.preventDefault();
add(text);
}
}
</script>
<div
class="{WELL} relative flex min-h-9 w-full flex-wrap items-center gap-1 px-1.5 py-1 sm:min-h-cladd-md sm:py-0.5"
>
{#each tags as tag, index (tag)}
<span
class="inline-flex h-6 items-center gap-1 rounded-[5px] bg-cladd-surface-next pr-0.5 pl-1.5 text-[12px] text-cladd-fg sm:h-5"
>
{tag}
<button
aria-label="Remove tag {tag}"
class="grid size-4 place-items-center rounded text-cladd-fg-soft hover:bg-black/10 hover:text-cladd-fg"
onclick={() => remove(index)}
tabindex="-1"
type="button"
>
<X class="size-3" />
</button>
</span>
{/each}
<AutocompleteInput
bare
class="min-w-24 flex-1"
free
{id}
inputClass="h-7 w-full min-w-0 bg-transparent px-1 text-[16px] outline-none placeholder:text-cladd-fg-softer sm:h-6 sm:text-[13px]"
{invalid}
onblur={() => add(text)}
onenter={handleEnter}
onkeydown={handleKey}
onpick={(option) => add(option.label)}
placeholder={tags.length ? "" : placeholder}
search={(query) => search(query, tags)}
bind:ref
bind:value={text}
/>
</div>
+162
View File
@@ -0,0 +1,162 @@
import { addDays, iso, today } from "#lib/dates.js";
const SPACES = /\s+/g;
const NUMBER = /^\d[\d.,]*/;
const OPERATOR = /[+\-*/()]/;
const ISO_DAY = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const DOTTED = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/;
const RELATIVE = /^[+-]\d{1,4}$/;
const EXPRESSION = /\d\s*[+\-*/]\s*[\d(]/;
function toDecimal(raw: string): number | null {
const comma = raw.lastIndexOf(",");
const dot = raw.lastIndexOf(".");
let text = raw;
if (comma !== -1 && dot !== -1) {
const decimal = comma > dot ? "," : ".";
const thousands = decimal === "," ? "." : ",";
text = raw.split(thousands).join("").replace(decimal, ".");
} else if (comma !== -1) {
const parts = raw.split(",");
const fraction = parts.at(-1) ?? "";
text =
parts.length === 2 && fraction.length <= 2
? parts.join(".")
: parts.join("");
}
const value = Number(text);
return Number.isFinite(value) ? value : null;
}
function tokenize(input: string): (number | string)[] | null {
const tokens: (number | string)[] = [];
let rest = input;
while (rest) {
const number = rest.match(NUMBER);
if (number) {
const value = toDecimal(number[0]);
if (value === null) {
return null;
}
tokens.push(value);
rest = rest.slice(number[0].length);
} else if (OPERATOR.test(rest[0])) {
tokens.push(rest[0]);
rest = rest.slice(1);
} else {
return null;
}
}
return tokens;
}
function evaluate(tokens: (number | string)[]): number | null {
let index = 0;
const peek = () => tokens[index];
const next = () => {
index += 1;
return tokens[index - 1];
};
const factor = (): number | null => {
const token = next();
if (token === "-") {
const value = factor();
return value === null ? null : -value;
}
if (token === "(") {
const value = sum();
return next() === ")" ? value : null;
}
return typeof token === "number" ? token : null;
};
const product = (): number | null => {
let left = factor();
while (left !== null && (peek() === "*" || peek() === "/")) {
const op = next();
const right = factor();
if (right === null) {
return null;
}
left = op === "*" ? left * right : left / right;
}
return left;
};
const sum = (): number | null => {
let left = product();
while (left !== null && (peek() === "+" || peek() === "-")) {
const op = next();
const right = product();
if (right === null) {
return null;
}
left = op === "+" ? left + right : left - right;
}
return left;
};
const value = sum();
return index === tokens.length && value !== null && Number.isFinite(value)
? value
: null;
}
export function evalAmount(input: string): number | null {
const text = input.replace(SPACES, "");
if (!text) {
return null;
}
const tokens = tokenize(text);
return tokens ? evaluate(tokens) : null;
}
export const isExpression = (input: string) =>
EXPRESSION.test(input) || input.includes("(");
export function amountString(input: string, decimals = 2): string {
const value = evalAmount(input);
return value === null ? "" : value.toFixed(decimals);
}
function valid(year: number, month: number, day: number): string | null {
const date = new Date(year, month - 1, day);
return date.getMonth() === month - 1 && date.getDate() === day
? iso(date)
: null;
}
const WORDS: Record<string, number> = {
tod: 0,
today: 0,
tom: 1,
tomorrow: 1,
yd: -1,
yest: -1,
yesterday: -1,
};
export function parseDate(input: string, base = today()): string | null {
const text = input.trim().toLowerCase();
if (!text) {
return null;
}
if (text in WORDS) {
return addDays(base, WORDS[text]);
}
if (RELATIVE.test(text)) {
return addDays(base, Number(text));
}
const full = text.match(ISO_DAY);
if (full) {
return valid(Number(full[1]), Number(full[2]), Number(full[3]));
}
const dotted = text.match(DOTTED);
if (dotted) {
const year = Number(base.slice(0, 4));
const raw = dotted[3] ? Number(dotted[3]) : year;
return valid(
raw < 100 ? 2000 + raw : raw,
Number(dotted[2]),
Number(dotted[1])
);
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
export const WELL =
"cladd-cut cladd-hoverable cladd-focusable-input rounded-cladd-md text-cladd-fg";
export const INPUT = `${WELL} flex h-9 w-full min-w-0 px-2.5 text-[16px] placeholder:text-cladd-fg-softer disabled:cursor-not-allowed disabled:opacity-60 sm:h-cladd-md sm:text-[13px]`;
export const LIST =
"cladd-surface cladd-outline absolute top-full right-0 left-0 z-50 mt-1 max-h-72 overflow-y-auto rounded-cladd-md py-1 shadow-lg";