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:
1 parent
58b356ea8b
commit
349a6a0b32
14 files changed
+2137
-683
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}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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) }));
|
||||||
|
}
|
||||||
@@ -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 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
<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}
|
{/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 };
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user