feat(transactions): the transaction page as a receipt with context

The amount is the headline, notes come first at full width, then the
last twelve visits to the same place as a dot timeline next to the
paying account's days around it with the running balance. Fields edit in
place: j and k move, Enter opens and saves, Esc cancels, s c, s b, s t
and s s jump to classification. A matching subscription shows as a
suggestion, L links it.
This commit is contained in:
hh committed 2026-10-16 15:51:00 +00:00
1 parent 58b356ea8b
commit 349a6a0b32
14 files changed
+2102 -648

No files matched your search

@@ -10,18 +10,21 @@
id, id,
invalid = false, invalid = false,
placeholder = "", placeholder = "",
onpick,
}: { }: {
value: Ref; value: Ref;
search: (query: string) => Option[]; search: (query: string) => Option[];
id: string; id: string;
invalid?: boolean; invalid?: boolean;
placeholder?: string; placeholder?: string;
onpick?: (option: Option) => void;
} = $props(); } = $props();
</script> </script>
<AutocompleteInput <AutocompleteInput
{id} {id}
{invalid} {invalid}
{onpick}
openOnFocus openOnFocus
{placeholder} {placeholder}
{search} {search}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Item, TransactionGroup } from "#lib/api/types.js";
import Place from "./Place.svelte";
import SameDays from "./SameDays.svelte";
import { loadPlace, placeOf, visitOf, visitsIn } from "./usual.js";
const OWN = {
deposit: "destination",
transfer: "source",
withdrawal: "source",
} as const;
const TONE: Record<string, string> = {
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
};
let { group }: { group: Item<TransactionGroup> } = $props();
const splits = $derived(group.attributes.transactions);
const first = $derived(splits[0]);
const day = $derived(first.date.slice(0, 10));
const currency = $derived({
code: first.currency_code,
decimals: first.currency_decimal_places,
symbol: first.currency_symbol,
});
const currencies = $derived(
[
currency,
{
code: first.foreign_currency_code,
decimals: first.foreign_currency_decimal_places,
symbol: first.foreign_currency_symbol,
},
].filter((option) => option.code)
);
const place = $derived(placeOf(splits));
const own = $derived.by(() => {
const side = OWN[first.type as keyof typeof OWN];
const id = side ? first[`${side}_id`] : null;
return side && id ? { id, name: first[`${side}_name`] ?? "" } : null;
});
const history = createQuery(() => ({
enabled: place !== null,
queryFn: () =>
place ? loadPlace(place, group.id, day) : Promise.resolve([]),
queryKey: ["receipt", "place", group.id, place?.id, day],
}));
const best = $derived.by(() => {
if (!place) {
return null;
}
const options = currencies.flatMap((option) => {
const current = visitOf(group, place, option.code ?? "");
return current
? [
{
currency: option,
current,
past: visitsIn(history.data ?? [], place, option.code ?? ""),
},
]
: [];
});
return options.reduce<(typeof options)[number] | null>(
(top, option) =>
top && top.past.length >= option.past.length ? top : option,
null
);
});
</script>
{#if best || own}
<div class="grid items-stretch gap-3 lg:grid-cols-2">
{#if place && best}
<Place
currency={best.currency}
current={best.current}
loading={history.isPending}
past={best.past}
{place}
tone={TONE[first.type] ?? "text-cladd-fg"}
/>
{/if}
{#if own}
<SameDays account={own} {currency} {day} groupId={group.id} />
{/if}
</div>
{/if}
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import Paperclip from "@lucide/svelte/icons/paperclip";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, lists } from "#lib/api/lists.js";
import type {
Attachment,
Item,
LinkType,
One,
Page,
PiggyBankEvent,
Split,
TransactionGroup,
TransactionLink,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { formatNumber } from "#lib/format.js";
import {
journalId,
type Signed,
signedAmount,
} from "#lib/transactions/split.js";
interface Linked {
amount: Signed | null;
description: string;
group: string | null;
id: string;
notes: string;
phrase: string;
}
type Event = Item<PiggyBankEvent> & {
links?: Record<string, { uri?: string }>;
};
const PIGGY_URI = /piggy-banks\/(\d+)/;
const BYTES_KB = 1024;
let { id, splits }: { id: string; splits: Split[] } = $props();
const journals = $derived(splits.map(journalId));
const attachments = createQuery(() => ({
queryFn: () =>
api<Page<Attachment>>(`/v1/transactions/${id}/attachments`, {
query: { limit: 500 },
}),
queryKey: ["transaction-attachments", id],
}));
const events = createQuery(() => ({
queryFn: () =>
api<Page<PiggyBankEvent>>(`/v1/transactions/${id}/piggy-bank-events`, {
query: { limit: 500 },
}),
queryKey: ["transaction-piggy-events", id],
}));
const piggies = createQuery(() => ({
...lists.piggyBanks(),
enabled: (events.data?.data.length ?? 0) > 0,
}));
async function loadLinks(ids: string[]): Promise<Linked[]> {
const [types, ...pages] = await Promise.all([
all<LinkType>("/v1/link-types"),
...ids.map((journal) =>
api<Page<TransactionLink>>(`/v1/transaction-journals/${journal}/links`)
),
]);
const found = await Promise.all(
ids.flatMap((journal, index) =>
pages[index].data.map(async (link) => {
const { inward_id, outward_id, link_type_id, notes } =
link.attributes;
const source = inward_id === journal;
const other = source ? outward_id : inward_id;
const linkType = types.find((t) => t.id === link_type_id);
const group = await api<One<TransactionGroup>>(
`/v1/transaction-journals/${other}`
).catch(() => null);
const split = group?.data.attributes.transactions.find(
(s) => journalId(s) === other
);
return {
amount: split ? signedAmount(split) : null,
description: split?.description ?? `#${other}`,
group: group?.data.id ?? null,
id: link.id,
notes: notes ?? "",
phrase:
(source
? linkType?.attributes.outward
: linkType?.attributes.inward) ?? "linked to",
};
})
)
);
return [...new Map(found.map((link) => [link.id, link])).values()];
}
const links = createQuery(() => ({
enabled: journals.length > 0,
queryFn: () => loadLinks(journals),
queryKey: ["transaction-links", id, journals.join(",")],
}));
async function removeLink(linkId: string) {
await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" });
await links.refetch();
}
function piggyOf(event: Event) {
const uri = Object.values(event.links ?? {}).find((l) => l?.uri)?.uri;
const piggyId = uri?.match(PIGGY_URI)?.[1];
const name = (piggies.data ?? []).find((p) => p.id === piggyId)?.attributes
.name;
return { id: piggyId, name: name ?? (piggyId ? `#${piggyId}` : "") };
}
const files = $derived(attachments.data?.data ?? []);
const piggyEvents = $derived((events.data?.data ?? []) as Event[]);
const linked = $derived(links.data ?? []);
const count = $derived(
[files, piggyEvents, linked].filter((list) => list.length > 0).length
);
</script>
{#if count > 0}
<div class="grid items-stretch gap-3 {count > 1 ? "lg:grid-cols-2" : ""}">
{#if linked.length}
<Box body="p-0" title="Transaction links">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each linked as link (link.id)}
<li class="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5">
<span class="text-cladd-fg-soft">{link.phrase}</span>
{#if link.group}
<a
class="text-brand hover:underline"
href="/transactions/show/{link.group}"
>{link.description}</a
>
{:else}
<span>{link.description}</span>
{/if}
{#if link.amount}
<Amount
amount={link.amount.amount}
code={link.amount.code}
decimals={link.amount.decimals}
symbol={link.amount.symbol}
tone={link.amount.tone}
/>
{/if}
{#if link.notes}
<span class="text-cladd-fg-soft">({link.notes})</span>
{/if}
<ConfirmButton
class="ml-auto"
confirmLabel="Press again to remove"
onconfirm={() => removeLink(link.id)}
>
Remove link
</ConfirmButton>
</li>
{/each}
</ul>
</Box>
{/if}
{#if files.length}
<Box body="p-0" title="Attachments">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each files as file (file.id)}
<li class="flex flex-col gap-0.5 px-3 py-1.5">
<span class="flex items-center gap-2">
<Paperclip class="size-3.5 text-cladd-fg-soft" />
<a
class="min-w-0 truncate text-brand hover:underline"
data-sveltekit-reload
href="/attachments/view/{file.id}"
>{file.attributes.title || file.attributes.filename}</a
>
<span class="num text-cladd-fg-soft"
>{formatNumber((file.attributes.size ?? 0) / BYTES_KB, 1)}
KB</span
>
<a
class="ml-auto text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
data-sveltekit-reload
href="/attachments/download/{file.id}"
>Download</a
>
</span>
{#if file.attributes.notes}
<span class="whitespace-pre-line text-[12px] text-cladd-fg-soft"
>{file.attributes.notes}</span
>
{/if}
</li>
{/each}
</ul>
</Box>
{/if}
{#if piggyEvents.length}
<Box body="p-0" title="Piggy banks">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each piggyEvents as piggyEvent (piggyEvent.id)}
{@const piggy = piggyOf(piggyEvent)}
<li class="flex items-center justify-between gap-3 px-3 py-1.5">
{#if piggy.id}
<a
class="text-brand hover:underline"
href="/piggy-banks/show/{piggy.id}"
>{piggy.name}</a
>
{:else}
<span>Piggy bank</span>
{/if}
<Amount
amount={piggyEvent.attributes.amount}
code={piggyEvent.attributes.currency_code}
decimals={piggyEvent.attributes.currency_decimal_places}
symbol={piggyEvent.attributes.currency_symbol}
/>
</li>
{/each}
</ul>
</Box>
{/if}
</div>
{/if}
@@ -0,0 +1,382 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import type { Split } from "#lib/api/types.js";
import AccountField from "#lib/components/editor/AccountField.svelte";
import type { Catalog } from "#lib/components/editor/catalog.svelte.js";
import {
type AccountRef,
CASH,
emptyRef,
FREE_KIND,
KINDS,
OWN_SIDE,
type Ref,
SHARED,
type Side,
type TxType,
} from "#lib/components/editor/model.js";
import RefField from "#lib/components/editor/RefField.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 { amountString, evalAmount } from "#lib/components/inputs/parse.js";
import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import type { Patch } from "#lib/transactions/mass.js";
import { FIELD_LABEL, type FieldName, type Row } from "./fields.js";
type Outcome = { patch: Patch; all: boolean } | { problem: string } | null;
let {
row,
split,
type,
catalog,
onsave,
oncancel,
}: {
row: Row;
split: Split;
type: TxType;
catalog: Catalog;
onsave: (patch: Patch, all: boolean) => Promise<string | null>;
oncancel: () => void;
} = $props();
const field = $derived(row.field);
const side = $derived<Side>(
row.field === "destination" ? "destination" : "source"
);
const decimals = (value: number | null | undefined) => value ?? 2;
const fixed = (value: string | null | undefined, places: number) =>
value ? Number(value).toFixed(places) : "";
function initialText() {
if (row.field === "category") {
return split.category_name ?? "";
}
return (row.field === "notes" ? split.notes : split.description) ?? "";
}
function initialAccount(): AccountRef {
const id = split[`${side}_id`] ?? null;
const kind = split[`${side}_type`] ?? "";
return kind === CASH
? { currency: null, id: null, kind: CASH, name: "" }
: { currency: null, id, kind, name: split[`${side}_name`] ?? "" };
}
function initialRef(): Ref {
const id = row.field === "bill" ? split.bill_id : split.budget_id;
const name = row.field === "bill" ? split.bill_name : split.budget_name;
return id ? { id, name: name ?? "" } : emptyRef();
}
let root = $state<HTMLElement | null>(null);
const start = untrack(() => ({
account: initialAccount(),
amount: fixed(split.amount, decimals(split.currency_decimal_places)),
date: split.date.slice(0, 10),
foreign: fixed(
split.foreign_amount,
decimals(split.foreign_currency_decimal_places)
),
ref: initialRef(),
tags: [...(split.tags ?? [])],
text: initialText(),
}));
let text = $state(start.text);
let date = $state(start.date);
let amount = $state(start.amount);
let foreign = $state(start.foreign);
let account = $state<AccountRef>(start.account);
let ref = $state<Ref>(start.ref);
let tags = $state<string[]>(start.tags);
let saving = $state(false);
let problem = $state("");
const positive = (value: string) => (evalAmount(value) ?? 0) > 0;
function amountOutcome(): Outcome {
if (!positive(amount)) {
return { problem: "The amount must be a positive number" };
}
const patch: Patch = {
amount: amountString(amount, decimals(split.currency_decimal_places)),
};
const code = split.foreign_currency_code;
if (code && !foreign.trim() && type === "transfer") {
return { problem: `Enter the amount in ${code}` };
}
if (code && foreign.trim() && !positive(foreign)) {
return { problem: "The foreign amount must be a positive number" };
}
if (code) {
patch.foreign_amount = foreign.trim()
? amountString(foreign, decimals(split.foreign_currency_decimal_places))
: "0";
}
const same =
Number(patch.amount) === Number(split.amount) &&
(!code || Number(patch.foreign_amount) === Number(split.foreign_amount));
return same ? null : { all: false, patch };
}
function accountOutcome(): Outcome {
const name = account.name.trim();
const all = SHARED[type][side];
const other = split[`${side === "source" ? "destination" : "source"}_id`];
if (!(account.id || (name && FREE_KIND[type][side]))) {
return { problem: `Choose the ${FIELD_LABEL[row.field]}` };
}
if (account.id && !KINDS[type][side].includes(account.kind)) {
return {
problem: `A ${account.kind.toLowerCase()} can't be the ${side} of a ${type}`,
};
}
if (account.id && account.id === other) {
return { problem: "Source and destination are the same account" };
}
const want =
side === OWN_SIDE[type]
? split.currency_code
: type === "transfer" &&
(split.foreign_currency_code ?? split.currency_code);
if (want && account.currency && account.currency.code !== want) {
return {
problem: `${account.name} is in ${account.currency.code}, this amount is in ${want}. Press e to change both`,
};
}
if (account.id === split[`${side}_id`]) {
return null;
}
return {
all,
patch: account.id
? { [`${side}_id`]: account.id }
: { [`${side}_name`]: name },
};
}
function textOutcome(
key: "description" | "notes" | "category_name",
current: string | null | undefined
): Outcome {
const value = text.trim() || null;
return value === (current || null)
? null
: { all: false, patch: { [key]: value } };
}
function dateOutcome(): Outcome {
if (!date) {
return { problem: "Enter a date like today, -1, 14.10 or 2026-10-14" };
}
return date === split.date.slice(0, 10)
? null
: { all: true, patch: { date: `${date}T${split.date.slice(11, 19)}` } };
}
function refOutcome(key: "bill_id" | "budget_id"): Outcome {
if (!ref.id && ref.name.trim()) {
return { problem: `Pick an existing ${FIELD_LABEL[row.field]}` };
}
return (ref.id ?? null) === (split[key] ?? null)
? null
: { all: false, patch: { [key]: ref.id } };
}
const OUTCOMES: Record<FieldName, () => Outcome> = {
amount: amountOutcome,
bill: () => refOutcome("bill_id"),
budget: () => refOutcome("budget_id"),
category: () => textOutcome("category_name", split.category_name),
date: dateOutcome,
description: () =>
text.trim()
? textOutcome("description", split.description)
: { problem: "Describe the transaction" },
destination: accountOutcome,
notes: () => textOutcome("notes", split.notes),
source: accountOutcome,
tags: () =>
JSON.stringify(tags) === JSON.stringify(split.tags ?? [])
? null
: { all: false, patch: { tags } },
};
async function submit() {
if (saving) {
return;
}
const result = OUTCOMES[row.field]();
if (result === null) {
oncancel();
return;
}
if ("problem" in result) {
({ problem } = result);
return;
}
saving = true;
problem = (await onsave(result.patch, result.all)) ?? "";
saving = false;
}
function pickSaves() {
queueMicrotask(() => {
if (root?.contains(document.activeElement)) {
submit();
}
});
}
onMount(async () => {
await tick();
const input = root?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"input:not([type=date]):not([tabindex='-1']), textarea"
);
input?.focus();
if (row.field !== "notes") {
input?.select();
}
});
useKeys(() => [
{
group: "Field",
input: true,
keys: "Escape",
label: "Cancel the field",
run: oncancel,
},
{
group: "Field",
input: true,
keys: "mod+Enter",
label: "Save the field",
run: submit,
},
]);
const id = $derived(`receipt-${row.key.replace(":", "-")}`);
const BUTTON =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2 font-semibold text-[12px] text-cladd-fg disabled:opacity-60";
</script>
<form
aria-label="Edit {FIELD_LABEL[field]}"
class="min-w-0 flex-wrap items-center gap-x-2 gap-y-1 {field === "notes" ||
field === "description"
? "flex w-full"
: "inline-flex align-middle"}"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
bind:this={root}
>
{#if field === "description"}
<input
aria-label="Description"
class="{INPUT} max-w-2xl flex-1 sm:text-[15px]"
{id}
bind:value={text}
>
{:else if field === "notes"}
<textarea
aria-label="Notes"
class="{INPUT} h-auto max-h-[60vh] min-h-24 w-full py-1.5 leading-relaxed [field-sizing:content]"
{id}
bind:value={text}
></textarea>
{:else if field === "category"}
<AutocompleteInput
aria-label="Category"
class="w-56"
free
{id}
onpick={pickSaves}
openOnFocus
placeholder="(no category)"
search={catalog.categoryOptions}
bind:value={text}
/>
{:else if field === "budget" || field === "bill"}
<div class="w-56">
<RefField
{id}
onpick={pickSaves}
placeholder={field === "bill" ? "(no subscription)" : "(no budget)"}
search={field === "bill" ? catalog.billOptions : catalog.budgetOptions}
bind:value={ref}
/>
</div>
{:else if field === "tags"}
<div class="w-72">
<TagsInput
{id}
placeholder="Comma or Enter between tags"
search={(query, taken) => catalog.tagOptions(query, taken)}
bind:tags
/>
</div>
{:else if field === "date"}
<DateInput
aria-label="Date"
class="w-56"
{id}
placeholder="today, -1, 14.10"
bind:value={date}
/>
{:else if field === "amount"}
<AmountInput
aria-label="Amount"
class="w-40"
currency={split.currency_code ?? ""}
decimals={decimals(split.currency_decimal_places)}
{id}
bind:value={amount}
/>
{#if split.foreign_currency_code}
<AmountInput
aria-label="Amount in {split.foreign_currency_code}"
class="w-36"
currency={split.foreign_currency_code}
decimals={decimals(split.foreign_currency_decimal_places)}
bind:value={foreign}
/>
{/if}
{:else}
<div class="w-72">
<AccountField
{catalog}
free={Boolean(FREE_KIND[type][side])}
{id}
kinds={KINDS[type][side]}
onpick={pickSaves}
placeholder={FREE_KIND[type][side] ? "a new name" : ""}
bind:value={account}
/>
</div>
{/if}
<span class="flex items-center gap-1.5">
<button class={BUTTON} disabled={saving} type="submit">
Save
<span class="kbd hidden lg:inline-flex"
>{field === "notes" ? prettyKeys("mod+Enter")[0] : "↵"}</span
>
</button>
<button class={BUTTON} onclick={oncancel} type="button">
Cancel <span class="kbd hidden lg:inline-flex">Esc</span>
</button>
</span>
{#if saving || problem}
<span
class="text-[12px] {problem ? "text-expense" : "text-cladd-fg-soft"}"
role="status"
>{saving ? "Saving" : problem}</span
>
{/if}
</form>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { blocks, type Inline } from "./notes.js";
let { text }: { text: string } = $props();
const parsed = $derived(blocks(text));
</script>
{#snippet line(
parts: Inline[]
)}
{#each parts as part, index (index)}
{#if part.kind === "link"}
<a
class="break-all text-brand hover:underline"
href={part.href}
rel="noopener noreferrer nofollow"
target="_blank"
>{part.text}</a
>
{:else if part.kind === "bold"}
<strong class="font-semibold">{part.text}</strong>
{:else if part.kind === "code"}
<code class="rounded-[4px] bg-cladd-surface-next px-1 text-[0.92em]"
>{part.text}</code
>
{:else}
{part.text}
{/if}
{/each}
{/snippet}
<div class="flex max-w-[92ch] flex-col gap-2 break-words">
{#each parsed as block, index (index)}
{#if block.kind === "head"}
<p class="font-semibold">{@render line(block.line)}</p>
{:else if block.kind === "list"}
<svelte:element
class="flex flex-col gap-0.5 pl-5 {block.ordered
? "list-decimal"
: "list-disc"}"
this={block.ordered ? "ol" : "ul"}
>
{#each block.items as item, i (i)}
<li>{@render line(item)}</li>
{/each}
</svelte:element>
{:else}
<p>
{#each block.lines as row, i (i)}
{#if i > 0}
<br>
{/if}
{@render line(row)}
{/each}
</p>
{/if}
{/each}
</div>
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { parse } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import {
cadence,
type Place,
usualOf,
type Visit,
verdict,
} from "./usual.js";
const HEIGHT = 44;
const R_MAX = 15;
const R_MIN = 3;
const SPREAD = 2.4;
const RECENT = 6;
let {
place,
current,
past,
currency,
tone,
loading = false,
}: {
place: Place;
current: Visit;
past: Visit[];
currency: Omit<Money, "amount">;
tone: string;
loading?: boolean;
} = $props();
let width = $state(0);
let active = $state<Visit | null>(null);
const visits = $derived(
[current, ...past].sort((a, b) => a.date.localeCompare(b.date))
);
const usual = $derived(usualOf(current, past));
const said = $derived(usual ? verdict(usual.change) : null);
const span = $derived.by(() => {
const times = visits.map((visit) => parse(visit.date).getTime());
return { from: Math.min(...times), to: Math.max(...times) };
});
const biggest = $derived(Math.max(...visits.map((visit) => visit.amount), 1));
const largest = $derived(
Math.max(R_MIN + 3, Math.min(R_MAX, width / (visits.length * SPREAD)))
);
const recent = $derived(past.slice(0, RECENT));
const money = (amount: number) => formatMoney({ ...currency, amount });
function x(visit: Visit) {
const room = Math.max(width - 2 * largest, 0);
const length = span.to - span.from;
const at = length ? (parse(visit.date).getTime() - span.from) / length : 1;
return largest + at * room;
}
const radius = (visit: Visit) =>
R_MIN + (largest - R_MIN) * Math.sqrt(visit.amount / biggest);
function stroke(visit: Visit) {
if (visit === current) {
return 0;
}
return active === visit ? 2 : 1.25;
}
const label = (visit: Visit) =>
`${formatDate(visit.date)}, ${visit.description}, ${money(visit.amount)}`;
</script>
<Box
body="flex flex-col gap-2 pt-2.5"
href="/accounts/show/{place.id}"
title="{place.from ? "Same route" : "At this place"}{past.length
? `, last ${past.length}`
: ""}"
>
{#if loading}
<p class="px-3 pb-2.5 text-cladd-fg-soft">Loading visits...</p>
{:else if !past.length}
<p class="px-3 pb-2.5 text-cladd-fg-soft">
{place.from
? "First transfer on this route."
: `First time at ${place.name}.`}
</p>
{:else}
<p class="flex flex-wrap items-baseline gap-x-1.5 px-3">
{#if usual}
<span class="text-cladd-fg-soft">usual</span>
<span class="num font-medium">{money(usual.median)}</span>
{#if usual.every !== null}
<span class="text-cladd-fg-softer">·</span>
<span>{cadence(usual.every)}</span>
{/if}
{#if said}
<span class="text-cladd-fg-softer">·</span>
<span class={said.warn ? "text-warn" : "text-cladd-fg-soft"}
>this one {said.text}</span
>
{/if}
{:else}
<span class="text-cladd-fg-soft"
>{past.length === 1 ? "One visit" : `${past.length} visits`}
before this one</span
>
{/if}
</p>
<div class="px-3">
<div bind:clientWidth={width}>
{#if width}
<svg
aria-label="Visits by date, dot size is the amount"
class="block overflow-visible"
height={HEIGHT}
role="img"
{width}
>
<line
class="stroke-current text-cladd-fg-softest"
x1={largest}
x2={width - largest}
y1={HEIGHT / 2}
y2={HEIGHT / 2}
/>
{#each visits as visit (visit.group)}
{@const self = visit === current}
<a
aria-label={label(visit)}
href="/transactions/show/{visit.group}"
onblur={() => (active = null)}
onfocus={() => (active = visit)}
onpointerenter={() => (active = visit)}
onpointerleave={() => (active = null)}
>
<circle
class={self
? `fill-current ${tone}`
: `fill-cladd-surface stroke-current ${active === visit ? "text-cladd-fg" : "text-cladd-fg-soft"}`}
cx={x(visit)}
cy={HEIGHT / 2}
r={radius(visit)}
stroke-width={stroke(visit)}
/>
</a>
{/each}
</svg>
{/if}
</div>
</div>
<p
class="num flex items-baseline justify-between gap-3 px-3 text-[12px] text-cladd-fg-soft"
>
<span>{formatDate(visits[0].date)}</span>
<span>{formatDate(visits.at(-1)?.date)}</span>
</p>
<table class="mb-1 w-full border-cladd-outline border-t">
<tbody>
{#each recent as visit (visit.group)}
<tr
class="h-7 {active === visit ? "bg-row-hover" : ""}"
onpointerenter={() => (active = visit)}
onpointerleave={() => (active = null)}
>
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
{formatDate(visit.date, "short")}
</td>
<td class="w-full max-w-0 truncate px-2">
<a class="hover:underline" href="/transactions/show/{visit.group}"
>{visit.description}</a
>
</td>
<td class="num px-3 text-right whitespace-nowrap">
{money(visit.amount)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { Split, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
const DAYS = 2;
const AROUND = 5;
let {
account,
currency,
day,
groupId,
}: {
account: { id: string; name: string };
currency: Omit<Money, "amount">;
day: string;
groupId: string;
} = $props();
const start = $derived(addDays(day, -DAYS));
const end = $derived(addDays(day, DAYS));
const query = createQuery(() => ({
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${account.id}/transactions`, {
end,
start,
}),
queryKey: ["receipt", "same-days", account.id, start, end],
}));
function amountOn(split: Split) {
const signed = signedAmount(split, account.id);
const foreign =
split.foreign_currency_code === currency.code &&
split.currency_code !== currency.code;
if (!foreign) {
return signed;
}
const sign = Math.sign(signed.amount) || 1;
return {
...currency,
amount: sign * Number(split.foreign_amount),
tone: toneOf(split, sign < 0 ? -1 : 1),
};
}
const rows = $derived(
(query.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter(
(split) =>
split.source_id === account.id ||
split.destination_id === account.id
)
.map((split) => ({
balance:
split.source_id === account.id
? split.source_balance_after
: split.destination_balance_after,
date: split.date.slice(0, 10),
description: split.description,
group: group.id,
key: journalId(split),
money: amountOn(split),
self: group.id === groupId,
}))
)
.reverse()
);
const shown = $derived.by(() => {
const first = rows.findIndex((row) => row.self);
const last = rows.findLastIndex((row) => row.self);
return first < 0
? rows.slice(0, 2 * AROUND + 1)
: rows.slice(Math.max(0, first - AROUND), last + AROUND + 1);
});
</script>
{#snippet more()}
<a
class="text-brand hover:underline"
href="/accounts/show/{account.id}/{start}/{end}"
>All {rows.length} on {account.name} from {formatDate(start, "short")} to
{formatDate(end, "short")}</a
>
{/snippet}
<Box
body="p-0"
footer={shown.length < rows.length ? more : undefined}
href="/accounts/show/{account.id}/{start}/{end}"
title="Same days on {account.name}"
>
{#if query.isPending}
<p class="px-3 py-2.5 text-cladd-fg-soft">Loading {account.name}...</p>
{:else if query.error}
<p class="px-3 py-2.5 text-expense">{query.error.message}</p>
{:else}
<table class="w-full">
<thead>
<tr
class="h-8 border-cladd-outline border-b text-left font-medium text-[12px] text-cladd-fg-soft"
>
<th class="px-3 font-medium">Date</th>
<th class="px-2 font-medium">Description</th>
<th class="px-2 text-right font-medium">Amount</th>
<th class="px-3 text-right font-medium whitespace-nowrap">
Balance after
</th>
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
<tr
class="h-7 {row.self
? "bg-row-selected font-medium"
: "hover:bg-row-hover"}"
>
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
{formatDate(row.date, "short")}
</td>
<td class="w-full max-w-0 truncate px-2">
{#if row.self}
{row.description}
{:else}
<a class="hover:underline" href="/transactions/show/{row.group}"
>{row.description}</a
>
{/if}
</td>
<td class="px-2 text-right whitespace-nowrap">
<Amount
amount={row.money.amount}
code={row.money.code}
decimals={row.money.decimals}
symbol={row.money.symbol}
tone={row.money.tone}
/>
</td>
<td class="num px-3 text-right whitespace-nowrap">
{row.balance === null || row.balance === undefined
? ""
: formatMoney({ ...currency, amount: row.balance })}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+76
View File
@@ -0,0 +1,76 @@
import type { Split } from "#lib/api/types.js";
import {
isEditableType,
SHARED,
type Side,
} from "#lib/components/editor/model.js";
export type FieldName =
| "date"
| "description"
| "amount"
| "source"
| "destination"
| "category"
| "budget"
| "tags"
| "bill"
| "notes";
export interface Row {
field: FieldName;
index: number;
key: string;
}
export const FIELD_LABEL: Record<FieldName, string> = {
amount: "amount",
bill: "subscription",
budget: "budget",
category: "category",
date: "date",
description: "description",
destination: "destination account",
notes: "notes",
source: "source account",
tags: "tags",
};
const SIDES: Side[] = ["source", "destination"];
export const rowKey = (field: FieldName, index: number) => `${index}:${field}`;
export function rowsOf(splits: Split[]): Row[] {
const type = splits[0]?.type ?? "";
if (!isEditableType(type)) {
return [];
}
const many = splits.length > 1;
const withdrawal = type === "withdrawal";
const locked = splits.some((split) => split.reconciled);
const shared = SIDES.filter((side) => SHARED[type][side]);
const top: FieldName[] = many
? ["date", ...(locked ? [] : shared)]
: [
"date",
"description",
...(locked ? [] : (["amount", ...SIDES] as FieldName[])),
];
const own = (split: Split): FieldName[] => [
...(many ? (["description"] as const) : []),
...(many && !split.reconciled
? ["amount" as const, ...SIDES.filter((side) => !SHARED[type][side])]
: []),
"category",
...(withdrawal ? (["budget"] as const) : []),
"tags",
...(withdrawal ? (["bill"] as const) : []),
"notes",
];
return [
...top.map((field) => ({ field, index: 0 })),
...splits.flatMap((split, index) =>
own(split).map((field) => ({ field, index }))
),
].map((row) => ({ ...row, key: rowKey(row.field, row.index) }));
}
+92
View File
@@ -0,0 +1,92 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
Bill,
Item,
Page,
Split,
TransactionGroup,
} from "#lib/api/types.js";
import { addDays, addMonths } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { journalId } from "#lib/transactions/split.js";
const CENT = 0.005;
export interface Suggestion {
bill: Item<Bill>;
journal: string;
month: string;
paid: string | null;
}
export const openSplits = (splits: Split[]) =>
splits.filter(
(split) =>
split.type === "withdrawal" && !split.bill_id && split.destination_id
);
function priceIn(split: Split, code: string): number | null {
if (split.currency_code === code) {
return Math.abs(toNumber(split.amount));
}
return split.foreign_currency_code === code && split.foreign_amount
? Math.abs(toNumber(split.foreign_amount))
: null;
}
function fits(bill: Bill, split: Split): boolean {
const price = priceIn(split, bill.currency_code ?? "");
return (
bill.active === true &&
price !== null &&
price >= toNumber(bill.amount_min) - CENT &&
price <= toNumber(bill.amount_max) + CENT &&
(bill.date ?? "").slice(0, 10) <= split.date.slice(0, 10)
);
}
const width = (bill: Item<Bill>) =>
toNumber(bill.attributes.amount_max) - toNumber(bill.attributes.amount_min);
async function lastDestination(billId: string): Promise<string | null> {
const page = await api<Page<TransactionGroup>>(
`/v1/bills/${billId}/transactions`,
{ query: { limit: 1 } }
);
return page.data[0]?.attributes.transactions[0]?.destination_id ?? null;
}
export async function suggest(splits: Split[]): Promise<Suggestion[]> {
const open = openSplits(splits);
if (!open.length) {
return [];
}
const month = `${open[0].date.slice(0, 7)}-01`;
const bills = await all<Bill>("/v1/bills", {
end: addDays(addMonths(month, 1), -1),
start: month,
});
const candidates = open.map((split) => ({
bills: bills.filter((bill) => fits(bill.attributes, split)),
split,
}));
const ids = [
...new Set(candidates.flatMap((c) => c.bills.map((bill) => bill.id))),
];
const last = new Map(
await Promise.all(
ids.map(async (id) => [id, await lastDestination(id)] as const)
)
);
return candidates.flatMap(({ bills: options, split }) => {
const [bill] = options
.filter((option) => last.get(option.id) === split.destination_id)
.sort((a, b) => width(a) - width(b));
if (!bill) {
return [];
}
const paid = bill.attributes.paid_dates?.[0]?.date?.slice(0, 10) ?? null;
return [{ bill, journal: journalId(split), month, paid }];
});
}
+67
View File
@@ -0,0 +1,67 @@
export type Inline =
| { kind: "text" | "bold" | "code"; text: string }
| { kind: "link"; text: string; href: string };
export type Block =
| { kind: "head"; line: Inline[] }
| { kind: "list"; ordered: boolean; items: Inline[][] }
| { kind: "para"; lines: Inline[][] };
const TOKEN =
/\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"])/g;
const BULLET = /^\s*(?:[-*•]|(\d+)[.)])\s+(.*)$/;
const HEAD = /^#{1,6}\s+(.*)$/;
const LINES = /\r?\n/;
export function inline(text: string): Inline[] {
const out: Inline[] = [];
let last = 0;
for (const match of text.matchAll(TOKEN)) {
const [whole, bold, code, label, href, url] = match;
if (match.index > last) {
out.push({ kind: "text", text: text.slice(last, match.index) });
}
if (bold) {
out.push({ kind: "bold", text: bold });
} else if (code) {
out.push({ kind: "code", text: code });
} else {
out.push({ href: href ?? url, kind: "link", text: label ?? url });
}
last = match.index + whole.length;
}
if (last < text.length) {
out.push({ kind: "text", text: text.slice(last) });
}
return out;
}
export function blocks(notes: string): Block[] {
const result: Block[] = [];
let open: Block | null = null;
for (const raw of notes.split(LINES)) {
const line = raw.trimEnd();
const head = line.match(HEAD);
const bullet = line.match(BULLET);
if (!line.trim()) {
open = null;
} else if (head) {
result.push({ kind: "head", line: inline(head[1]) });
open = null;
} else if (bullet) {
const ordered = Boolean(bullet[1]);
if (open?.kind !== "list" || open.ordered !== ordered) {
open = { items: [], kind: "list", ordered };
result.push(open);
}
open.items.push(inline(bullet[2]));
} else {
if (open?.kind !== "para") {
open = { kind: "para", lines: [] };
result.push(open);
}
open.lines.push(inline(line));
}
}
return result;
}
+171
View File
@@ -0,0 +1,171 @@
import { api } from "#lib/api/client.js";
import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
import { addMonths, daysBetween } from "#lib/dates.js";
import { isCash } from "#lib/transactions/split.js";
const COUNT = 12;
const FETCH = 25;
const MIN_COUNT = 3;
const LOOKBACK_MONTHS = 60;
const PERCENT = 100;
const ABOUT = 5;
const NOTABLE = 15;
type Side = "source" | "destination";
export interface Place {
from: string | null;
id: string;
name: string;
side: Side;
type: string;
}
export interface Visit {
amount: number;
date: string;
description: string;
group: string;
}
export interface Usual {
change: number;
every: number | null;
median: number;
}
const COUNTER: Record<string, Side> = {
deposit: "source",
transfer: "destination",
withdrawal: "destination",
};
const CADENCE: [number, number, string][] = [
[0, 1.5, "daily"],
[5, 9, "weekly"],
[12, 17, "every two weeks"],
[26, 35, "monthly"],
[80, 100, "quarterly"],
[170, 195, "every half year"],
[345, 385, "yearly"],
];
export function placeOf(splits: Split[]): Place | null {
const [first] = splits;
const side = first ? COUNTER[first.type] : undefined;
const id = side ? first[`${side}_id`] : null;
if (!(side && id) || isCash(first[`${side}_type`])) {
return null;
}
return {
from: first.type === "transfer" ? first.source_id : null,
id,
name: first[`${side}_name`] ?? "",
side,
type: first.type,
};
}
function priceIn(split: Split, code: string): number | null {
if (split.currency_code === code) {
return Math.abs(Number(split.amount));
}
return split.foreign_currency_code === code && split.foreign_amount
? Math.abs(Number(split.foreign_amount))
: null;
}
export function visitOf(
group: Item<TransactionGroup>,
place: Place,
code: string
): Visit | null {
const splits = group.attributes.transactions.filter(
(split) =>
split[`${place.side}_id`] === place.id &&
(!place.from || split.source_id === place.from)
);
const prices = splits.map((split) => priceIn(split, code));
if (!splits.length || prices.includes(null)) {
return null;
}
return {
amount: prices.reduce<number>((sum, price) => sum + (price ?? 0), 0),
date: splits[0].date.slice(0, 10),
description:
group.attributes.group_title || splits[0].description || `#${group.id}`,
group: group.id,
};
}
export async function loadPlace(
place: Place,
groupId: string,
end: string
): Promise<Item<TransactionGroup>[]> {
const page = place.from
? await api<Page<TransactionGroup>>("/v1/search/transactions", {
query: {
limit: FETCH,
query: `type:transfer source_account_id:${place.from} destination_account_id:${place.id} date_before:${end}`,
},
})
: await api<Page<TransactionGroup>>(
`/v1/accounts/${place.id}/transactions`,
{
query: {
end,
limit: FETCH,
start: addMonths(end, -LOOKBACK_MONTHS),
type: place.type,
},
}
);
return page.data.filter((group) => group.id !== groupId);
}
export const visitsIn = (
groups: Item<TransactionGroup>[],
place: Place,
code: string
) =>
groups
.map((group) => visitOf(group, place, code))
.filter((visit): visit is Visit => visit !== null)
.slice(0, COUNT);
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function usualOf(current: Visit, past: Visit[]): Usual | null {
if (past.length < MIN_COUNT) {
return null;
}
const typical = median(past.map((visit) => visit.amount));
const dates = [current, ...past].map((visit) => visit.date).sort();
const gaps = dates.slice(1).map((date, i) => daysBetween(dates[i], date));
return {
change: typical ? Math.round((current.amount / typical - 1) * PERCENT) : 0,
every: gaps.length ? median(gaps) : null,
median: typical,
};
}
export function cadence(days: number): string {
return (
CADENCE.find(([low, high]) => days >= low && days <= high)?.[2] ??
`every ${Math.round(days)} days`
);
}
export function verdict(change: number) {
if (Math.abs(change) < ABOUT) {
return { text: "about usual", warn: false };
}
return change > 0
? { text: `${change}% above usual`, warn: change >= NOTABLE }
: { text: `${-change}% below usual`, warn: false };
}
@@ -4,6 +4,7 @@
import type { One, TransactionGroup } from "#lib/api/types.js"; import type { One, TransactionGroup } from "#lib/api/types.js";
import Editor from "#lib/components/editor/Editor.svelte"; import Editor from "#lib/components/editor/Editor.svelte";
import { fromGroup, isEditableType } from "#lib/components/editor/model.js"; import { fromGroup, isEditableType } from "#lib/components/editor/model.js";
import Context from "#lib/components/receipt/Context.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -41,6 +42,9 @@
> >
</p> </p>
{:else if group.data} {:else if group.data}
<div class="flex flex-col gap-3">
<Editor group={group.data.data} initial={fromGroup(group.data.data)} /> <Editor group={group.data.data} initial={fromGroup(group.data.data)} />
<Context group={group.data.data} />
</div>
{/if} {/if}
{/key} {/key}
File diff suppressed because it is too large. Load diff
@@ -1,97 +0,0 @@
import { api } from "#lib/api/client.js";
import type { Page, Split, TransactionGroup } from "#lib/api/types.js";
import { addMonths } from "#lib/dates.js";
import { isCash } from "#lib/transactions/split.js";
const COUNT = 10;
const MIN_COUNT = 3;
const LOOKBACK_MONTHS = 60;
const PERCENT = 100;
export interface Usual {
change: number;
count: number;
median: number;
query: string;
}
export const placesOf = (splits: Split[]) => [
...new Set(
splits
.filter(
(split) =>
split.type === "withdrawal" &&
split.destination_id &&
!isCash(split.destination_type)
)
.map((split) => split.destination_id ?? "")
),
];
export async function loadPast(
places: string[],
groupId: string,
end: string
): Promise<Record<string, Split[]>> {
const pages = await Promise.all(
places.map((place) =>
api<Page<TransactionGroup>>(`/v1/accounts/${place}/transactions`, {
query: {
end,
limit: COUNT + 1,
start: addMonths(end, -LOOKBACK_MONTHS),
type: "withdrawal",
},
})
)
);
return Object.fromEntries(
places.map((place, i) => [
place,
pages[i].data
.filter((group) => group.id !== groupId)
.flatMap((group) =>
group.attributes.transactions.filter(
(split) => split.destination_id === place
)
),
])
);
}
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function usualOf(
split: Split,
past: Record<string, Split[]> | undefined
): Usual | null {
const same = (past?.[split.destination_id ?? ""] ?? [])
.filter((item) => item.currency_code === split.currency_code)
.slice(0, COUNT);
if (same.length < MIN_COUNT) {
return null;
}
const typical = median(same.map((item) => Math.abs(Number(item.amount))));
return {
change: Math.round((Number(split.amount) / typical - 1) * PERCENT),
count: same.length,
median: typical,
query: `destination_account_id:${split.destination_id} currency_is:${split.currency_code} date_before:${split.date.slice(0, 10)}`,
};
}
const ABOUT = 5;
const NOTABLE = 15;
export function verdict(change: number) {
if (Math.abs(change) < ABOUT) {
return { text: "about usual", warn: false };
}
return change > 0
? { text: `+${change}% above usual`, warn: change >= NOTABLE }
: { text: `${-change}% below usual`, warn: false };
}