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
+2137 -683

No files matched your search

@@ -4,6 +4,7 @@
import type { One, TransactionGroup } from "#lib/api/types.js";
import Editor from "#lib/components/editor/Editor.svelte";
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 { page } from "$app/state";
@@ -41,6 +42,9 @@
>
</p>
{:else if group.data}
<Editor group={group.data.data} initial={fromGroup(group.data.data)} />
<div class="flex flex-col gap-3">
<Editor group={group.data.data} initial={fromGroup(group.data.data)} />
<Context group={group.data.data} />
</div>
{/if}
{/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 };
}