From 349a6a0b32e30cbc356e910d45cbb1a1f9ff5789 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 15:51:00 +0000 Subject: [PATCH] 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. --- src/lib/components/editor/RefField.svelte | 3 + src/lib/components/receipt/Context.svelte | 93 ++ src/lib/components/receipt/Extras.svelte | 235 ++++ src/lib/components/receipt/FieldEditor.svelte | 382 ++++++ src/lib/components/receipt/Notes.svelte | 59 + src/lib/components/receipt/Place.svelte | 186 +++ src/lib/components/receipt/SameDays.svelte | 158 +++ src/lib/components/receipt/fields.ts | 76 ++ src/lib/components/receipt/match.ts | 92 ++ src/lib/components/receipt/notes.ts | 67 + src/lib/components/receipt/usual.ts | 171 +++ .../transactions/edit/[id=id]/+page.svelte | 6 +- .../transactions/show/[id=id]/+page.svelte | 1195 +++++++++-------- src/routes/transactions/show/[id=id]/usual.ts | 97 -- 14 files changed, 2137 insertions(+), 683 deletions(-) create mode 100644 src/lib/components/receipt/Context.svelte create mode 100644 src/lib/components/receipt/Extras.svelte create mode 100644 src/lib/components/receipt/FieldEditor.svelte create mode 100644 src/lib/components/receipt/Notes.svelte create mode 100644 src/lib/components/receipt/Place.svelte create mode 100644 src/lib/components/receipt/SameDays.svelte create mode 100644 src/lib/components/receipt/fields.ts create mode 100644 src/lib/components/receipt/match.ts create mode 100644 src/lib/components/receipt/notes.ts create mode 100644 src/lib/components/receipt/usual.ts delete mode 100644 src/routes/transactions/show/[id=id]/usual.ts diff --git a/src/lib/components/editor/RefField.svelte b/src/lib/components/editor/RefField.svelte index a0b0acb..5b04c02 100644 --- a/src/lib/components/editor/RefField.svelte +++ b/src/lib/components/editor/RefField.svelte @@ -10,18 +10,21 @@ id, invalid = false, placeholder = "", + onpick, }: { value: Ref; search: (query: string) => Option[]; id: string; invalid?: boolean; placeholder?: string; + onpick?: (option: Option) => void; } = $props(); + 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 = { + deposit: "text-income", + transfer: "text-transfer", + withdrawal: "text-expense", + }; + + let { group }: { group: Item } = $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 + ); + }); + + +{#if best || own} +
+ {#if place && best} + + {/if} + {#if own} + + {/if} +
+{/if} diff --git a/src/lib/components/receipt/Extras.svelte b/src/lib/components/receipt/Extras.svelte new file mode 100644 index 0000000..e318070 --- /dev/null +++ b/src/lib/components/receipt/Extras.svelte @@ -0,0 +1,235 @@ + + +{#if count > 0} +
+ {#if linked.length} + +
    + {#each linked as link (link.id)} +
  • + {link.phrase} + {#if link.group} + {link.description} + {:else} + {link.description} + {/if} + {#if link.amount} + + {/if} + {#if link.notes} + ({link.notes}) + {/if} + removeLink(link.id)} + > + Remove link + +
  • + {/each} +
+
+ {/if} + + {#if files.length} + + + + {/if} + + {#if piggyEvents.length} + +
    + {#each piggyEvents as piggyEvent (piggyEvent.id)} + {@const piggy = piggyOf(piggyEvent)} +
  • + {#if piggy.id} + {piggy.name} + {:else} + Piggy bank + {/if} + +
  • + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/receipt/FieldEditor.svelte b/src/lib/components/receipt/FieldEditor.svelte new file mode 100644 index 0000000..9bfb4a6 --- /dev/null +++ b/src/lib/components/receipt/FieldEditor.svelte @@ -0,0 +1,382 @@ + + +
{ + event.preventDefault(); + submit(); + }} + bind:this={root} +> + {#if field === "description"} + + {:else if field === "notes"} + + {:else if field === "category"} + + {:else if field === "budget" || field === "bill"} +
+ +
+ {:else if field === "tags"} +
+ catalog.tagOptions(query, taken)} + bind:tags + /> +
+ {:else if field === "date"} + + {:else if field === "amount"} + + {#if split.foreign_currency_code} + + {/if} + {:else} +
+ +
+ {/if} + + + + + {#if saving || problem} + {saving ? "Saving" : problem} + {/if} + diff --git a/src/lib/components/receipt/Notes.svelte b/src/lib/components/receipt/Notes.svelte new file mode 100644 index 0000000..aaa6622 --- /dev/null +++ b/src/lib/components/receipt/Notes.svelte @@ -0,0 +1,59 @@ + + +{#snippet line( + parts: Inline[] +)} + {#each parts as part, index (index)} + {#if part.kind === "link"} + {part.text} + {:else if part.kind === "bold"} + {part.text} + {:else if part.kind === "code"} + {part.text} + {:else} + {part.text} + {/if} + {/each} +{/snippet} + +
+ {#each parsed as block, index (index)} + {#if block.kind === "head"} +

{@render line(block.line)}

+ {:else if block.kind === "list"} + + {#each block.items as item, i (i)} +
  • {@render line(item)}
  • + {/each} +
    + {:else} +

    + {#each block.lines as row, i (i)} + {#if i > 0} +
    + {/if} + {@render line(row)} + {/each} +

    + {/if} + {/each} +
    diff --git a/src/lib/components/receipt/Place.svelte b/src/lib/components/receipt/Place.svelte new file mode 100644 index 0000000..19ec8b5 --- /dev/null +++ b/src/lib/components/receipt/Place.svelte @@ -0,0 +1,186 @@ + + + + {#if loading} +

    Loading visits...

    + {:else if !past.length} +

    + {place.from + ? "First transfer on this route." + : `First time at ${place.name}.`} +

    + {:else} +

    + {#if usual} + usual + {money(usual.median)} + {#if usual.every !== null} + · + {cadence(usual.every)} + {/if} + {#if said} + · + this one {said.text} + {/if} + {:else} + {past.length === 1 ? "One visit" : `${past.length} visits`} + before this one + {/if} +

    +
    +
    + {#if width} + + + {#each visits as visit (visit.group)} + {@const self = visit === current} + (active = null)} + onfocus={() => (active = visit)} + onpointerenter={() => (active = visit)} + onpointerleave={() => (active = null)} + > + + + {/each} + + {/if} +
    +
    +

    + {formatDate(visits[0].date)} + {formatDate(visits.at(-1)?.date)} +

    + + + {#each recent as visit (visit.group)} + (active = visit)} + onpointerleave={() => (active = null)} + > + + + + + {/each} + +
    + {formatDate(visit.date, "short")} + + {visit.description} + + {money(visit.amount)} +
    + {/if} +
    diff --git a/src/lib/components/receipt/SameDays.svelte b/src/lib/components/receipt/SameDays.svelte new file mode 100644 index 0000000..b21a0ca --- /dev/null +++ b/src/lib/components/receipt/SameDays.svelte @@ -0,0 +1,158 @@ + + +{#snippet more()} + All {rows.length} on {account.name} from {formatDate(start, "short")} to + {formatDate(end, "short")} +{/snippet} + + + {#if query.isPending} +

    Loading {account.name}...

    + {:else if query.error} +

    {query.error.message}

    + {:else} + + + + + + + + + + + {#each shown as row (row.key)} + + + + + + + {/each} + +
    DateDescriptionAmount + Balance after +
    + {formatDate(row.date, "short")} + + {#if row.self} + {row.description} + {:else} + {row.description} + {/if} + + + + {row.balance === null || row.balance === undefined + ? "" + : formatMoney({ ...currency, amount: row.balance })} +
    + {/if} +
    diff --git a/src/lib/components/receipt/fields.ts b/src/lib/components/receipt/fields.ts new file mode 100644 index 0000000..dad6f4e --- /dev/null +++ b/src/lib/components/receipt/fields.ts @@ -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 = { + 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) })); +} diff --git a/src/lib/components/receipt/match.ts b/src/lib/components/receipt/match.ts new file mode 100644 index 0000000..a68a2a8 --- /dev/null +++ b/src/lib/components/receipt/match.ts @@ -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; + 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) => + toNumber(bill.attributes.amount_max) - toNumber(bill.attributes.amount_min); + +async function lastDestination(billId: string): Promise { + const page = await api>( + `/v1/bills/${billId}/transactions`, + { query: { limit: 1 } } + ); + return page.data[0]?.attributes.transactions[0]?.destination_id ?? null; +} + +export async function suggest(splits: Split[]): Promise { + const open = openSplits(splits); + if (!open.length) { + return []; + } + const month = `${open[0].date.slice(0, 7)}-01`; + const bills = await all("/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 }]; + }); +} diff --git a/src/lib/components/receipt/notes.ts b/src/lib/components/receipt/notes.ts new file mode 100644 index 0000000..efcfafd --- /dev/null +++ b/src/lib/components/receipt/notes.ts @@ -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; +} diff --git a/src/lib/components/receipt/usual.ts b/src/lib/components/receipt/usual.ts new file mode 100644 index 0000000..6d09953 --- /dev/null +++ b/src/lib/components/receipt/usual.ts @@ -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 = { + 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, + 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((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[]> { + const page = place.from + ? await api>("/v1/search/transactions", { + query: { + limit: FETCH, + query: `type:transfer source_account_id:${place.from} destination_account_id:${place.id} date_before:${end}`, + }, + }) + : await api>( + `/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[], + 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 }; +} diff --git a/src/routes/transactions/edit/[id=id]/+page.svelte b/src/routes/transactions/edit/[id=id]/+page.svelte index 8c6d587..daf98f8 100644 --- a/src/routes/transactions/edit/[id=id]/+page.svelte +++ b/src/routes/transactions/edit/[id=id]/+page.svelte @@ -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 @@ >

    {:else if group.data} - +
    + + +
    {/if} {/key} diff --git a/src/routes/transactions/show/[id=id]/+page.svelte b/src/routes/transactions/show/[id=id]/+page.svelte index 5c73425..368bb53 100644 --- a/src/routes/transactions/show/[id=id]/+page.svelte +++ b/src/routes/transactions/show/[id=id]/+page.svelte @@ -2,35 +2,56 @@ import Check from "@lucide/svelte/icons/check"; import ChevronDown from "@lucide/svelte/icons/chevron-down"; import Copy from "@lucide/svelte/icons/copy"; - import Paperclip from "@lucide/svelte/icons/paperclip"; import Pencil from "@lucide/svelte/icons/pencil"; import { createQuery } from "@tanstack/svelte-query"; import { DropdownMenu } from "bits-ui"; + import { untrack } from "svelte"; import { api } from "#lib/api/client.js"; - import { all, invalidate, lists } from "#lib/api/lists.js"; + import { invalidate } from "#lib/api/lists.js"; import type { - Attachment, + Bill, 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 { Catalog } from "#lib/components/editor/catalog.svelte.js"; + import { + META_DATE_LABEL, + META_DATES, + type TxType, + } from "#lib/components/editor/model.js"; + import Context from "#lib/components/receipt/Context.svelte"; + import Extras from "#lib/components/receipt/Extras.svelte"; + import FieldEditor from "#lib/components/receipt/FieldEditor.svelte"; + import { + FIELD_LABEL, + type FieldName, + type Row, + rowKey, + rowsOf, + } from "#lib/components/receipt/fields.js"; + import { openSplits, suggest } from "#lib/components/receipt/match.js"; + import Notes from "#lib/components/receipt/Notes.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte"; + import { + average, + money as billMoney, + isRange, + repeats, + } from "#lib/components/subscriptions/bills.js"; import TypeIcon from "#lib/components/transactions/TypeIcon.svelte"; import Button from "#lib/components/ui/button/button.svelte"; import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte"; import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte"; import DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte"; - import { formatDate, formatMoney, formatNumber } from "#lib/format.js"; + import { formatDate, formatDateTime, formatMoney } from "#lib/format.js"; import { useKeys } from "#lib/keys.svelte.js"; + import { ListNav } from "#lib/list-nav.svelte.js"; import { oldUiUrl } from "#lib/spa-paths.js"; + import { type Patch, updateGroups } from "#lib/transactions/mass.js"; import { accountUrl, cloneUrl, @@ -49,151 +70,122 @@ } from "#lib/transactions/split.js"; import { goto } from "$app/navigation"; import { page } from "$app/state"; - import { loadPast, placesOf, usualOf, verdict } from "./usual.js"; - interface Linked { - amount: Signed | null; - description: string; - group: string | null; - id: string; - notes: string; - phrase: string; - } - - type Event = Item & { - links?: Record; + const OWN: Record = { + deposit: "destination", + transfer: "source", + withdrawal: "source", }; - - const DATE_FIELDS = [ - ["interest_date", "Interest date"], - ["book_date", "Book date"], - ["process_date", "Processing date"], - ["due_date", "Due date"], - ["payment_date", "Payment date"], - ["invoice_date", "Invoice date"], + const TONE: Record = { + expense: "text-expense", + income: "text-income", + neutral: "text-cladd-fg", + transfer: "text-transfer", + }; + const PICKS: [string, FieldName][] = [ + ["s c", "category"], + ["s b", "budget"], + ["s t", "tags"], + ["s s", "bill"], + ]; + const REFERENCES = [ + ["external_id", "external ID"], + ["internal_reference", "reference"], + ["sepa_ct_id", "SEPA end to end ID"], + ["sepa_db", "SEPA mandate"], + ["sepa_ci", "SEPA creditor"], ] as const; - - const META_FIELDS = [ - ["external_id", "External ID"], - ["internal_reference", "Internal reference"], - ["sepa_batch_id", "SEPA batch ID"], - ["sepa_ct_id", "SEPA end to end identifier"], - ["sepa_ct_op", "SEPA opposing account identifier"], - ["sepa_db", "SEPA mandate identifier"], - ["sepa_country", "SEPA country"], - ["sepa_cc", "SEPA clearing code"], - ["sepa_ep", "SEPA external purpose"], - ["sepa_ci", "SEPA creditor identifier"], - ] as const; - - const PIGGY_URI = /piggy-banks\/(\d+)/; - const BYTES_KB = 1024; + const ERROR_PREFIX = /^#\d+: /; + const PLAIN = + "rounded-[4px] text-cladd-fg-softer hover:text-cladd-fg hover:underline"; let confirm = $state(); let deleteError = $state(""); + let editing = $state(null); + let linking = $state(""); + let linked = $state>(new Set()); const id = $derived(page.params.id ?? ""); - const groupQuery = createQuery(() => ({ queryFn: () => api>(`/v1/transactions/${id}`), queryKey: ["transaction", id], })); const group = $derived(groupQuery.data?.data); const splits = $derived(group?.attributes.transactions ?? []); + const first = $derived(splits[0]); const type = $derived(group ? groupType(group) : "withdrawal"); const title = $derived(group ? groupTitle(group) : ""); - const editable = $derived( + const many = $derived(splits.length > 1); + const actionable = $derived( ["withdrawal", "deposit", "transfer", "reconciliation"].includes(type) ); - const journals = $derived(splits.map(journalId)); + const rows = $derived(rowsOf(splits)); + const rowIndex = $derived(new Map(rows.map((row, i) => [row.key, i]))); + const catalog = new Catalog(); - const attachmentsQuery = createQuery(() => ({ - queryFn: () => - api>(`/v1/transactions/${id}/attachments`, { - query: { limit: 500 }, - }), - queryKey: ["transaction-attachments", id], - })); - const eventsQuery = createQuery(() => ({ - queryFn: () => - api>(`/v1/transactions/${id}/piggy-bank-events`, { - query: { limit: 500 }, - }), - queryKey: ["transaction-piggy-events", id], - })); - const piggies = createQuery(() => ({ - ...lists.piggyBanks(), - enabled: (eventsQuery.data?.data.length ?? 0) > 0, + const unlinked = $derived(openSplits(splits)); + const matchQuery = createQuery(() => ({ + enabled: unlinked.length > 0, + queryFn: () => suggest(splits), + queryKey: [ + "receipt", + "bill-match", + id, + unlinked.map((split) => `${journalId(split)}:${split.amount}`).join(","), + ], })); + const suggestions = $derived(unlinked.length ? (matchQuery.data ?? []) : []); - async function loadLinks(ids: string[]): Promise> { - const [types, ...pages] = await Promise.all([ - all("/v1/link-types"), - ...ids.map((journal) => - api>(`/v1/transaction-journals/${journal}/links`) - ), - ]); - const result: Record = {}; - await Promise.all( - ids.map(async (journal, index) => { - result[journal] = await Promise.all( - 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 found = await api>( - `/v1/transaction-journals/${other}` - ).catch(() => null); - const split = found?.data.attributes.transactions.find( - (s) => journalId(s) === other - ); - return { - amount: split ? signedAmount(split) : null, - description: split?.description ?? `#${other}`, - group: found?.data.id ?? null, - id: link.id, - notes: notes ?? "", - phrase: - (source - ? linkType?.attributes.outward - : linkType?.attributes.inward) ?? "linked to", - }; - }) - ); - }) - ); - return result; - } + const nav = new ListNav({ + id: "receipt", + items: () => rows, + key: (row) => row.key, + open: openRow, + }); + nav.use(); - const places = $derived(placesOf(splits)); - const groupDay = $derived(splits[0]?.date.slice(0, 10) ?? ""); - const pastQuery = createQuery(() => ({ - enabled: places.length > 0, - queryFn: () => loadPast(places, id, groupDay), - queryKey: ["transaction-usual", id, places.join(","), groupDay], - })); - - const linksQuery = createQuery(() => ({ - enabled: journals.length > 0, - queryFn: () => loadLinks(journals), - queryKey: ["transaction-links", id, journals.join(",")], - })); - - function uniqueAccounts(side: "source" | "destination") { - const seen = new Map(); - for (const split of splits) { - const accountId = split[`${side}_id`] ?? ""; - seen.set(accountId, { - cash: isCash(split[`${side}_type`]), - id: accountId, - name: split[`${side}_name`] ?? "", + $effect.pre(() => { + if (id) { + untrack(() => { + editing = null; + linked = new Set(); + linking = ""; + nav.cursor = -1; }); } - return [...seen.values()]; + }); + + const own = $derived(OWN[type]); + function balanceOf(side: "source" | "destination") { + const values = splits + .map((split) => split[`${side}_balance_after`]) + .filter((value) => value !== null && value !== undefined) + .map(Number); + if (!(values.length && first)) { + return null; + } + const foreign = + side === "destination" && + type === "transfer" && + Boolean(first.foreign_currency_code); + return formatMoney({ + amount: + side === "destination" ? Math.max(...values) : Math.min(...values), + code: foreign ? first.foreign_currency_code : first.currency_code, + decimals: foreign + ? first.foreign_currency_decimal_places + : first.currency_decimal_places, + symbol: foreign ? first.foreign_currency_symbol : first.currency_symbol, + }); } + const balances = $derived( + (type === "transfer" ? ["source", "destination"] : [own]) + .filter((side): side is "source" | "destination" => Boolean(side)) + .map(balanceOf) + .filter((value) => value !== null) + ); + const foreignTotals = $derived.by(() => { const sums = new Map(); for (const split of splits) { @@ -211,35 +203,80 @@ return [...sums.values()]; }); - function attachmentsFor(split: Split) { - return (attachmentsQuery.data?.data ?? []).filter( - (a) => a.attributes.attachable_id === journalId(split) - ); + const counterSide = $derived<"source" | "destination" | null>( + { deposit: "source", withdrawal: "destination" }[type as string] as + | "source" + | "destination" + | null + ); + + function openRow(row: Row) { + nav.cursor = rowIndex.get(row.key) ?? nav.cursor; + editing = row.key; } - function eventsFor(split: Split) { - return ((eventsQuery.data?.data ?? []) as Event[]).filter( - (e) => - (e.attributes as { transaction_journal_id?: string }) - .transaction_journal_id === journalId(split) - ); + function openField(field: FieldName, index: number) { + const at = rowIndex.get(rowKey(field, index)); + if (at !== undefined) { + openRow(rows[at]); + } } - 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}` : "") }; + async function save( + row: Row, + patch: Patch, + all: boolean + ): Promise { + const split = splits[row.index]; + const outcome = await updateGroups( + new Map([[id, all ? null : new Set([journalId(split)])]]), + () => patch, + () => undefined + ); + if (outcome.errors.length) { + return outcome.errors[0].replace(ERROR_PREFIX, ""); + } + await invalidate(); + editing = null; + return null; + } + + async function link() { + if (!suggestions.length || linking === "Linking") { + return; + } + linking = "Linking"; + const bills = new Map(suggestions.map((s) => [s.journal, s.bill.id])); + const outcome = await updateGroups( + new Map([[id, new Set(bills.keys())]]), + (split) => ({ bill_id: bills.get(journalId(split)) }), + () => undefined + ); + if (outcome.errors.length) { + linking = outcome.errors[0].replace(ERROR_PREFIX, ""); + return; + } + await invalidate(); + linked = new Set(bills.keys()); + linking = ""; } function dateLabel(value: string | undefined) { if (!value) { return ""; } + const day = value.slice(0, 10); const time = value.slice(11, 16); - const day = formatDate(value.slice(0, 10), "long"); - return time && time !== "00:00" ? `${day}, ${time}` : day; + const label = `${formatDate(day, "day")} ${day.slice(0, 4)}`; + return time && time !== "00:00" ? `${label}, ${time}` : label; + } + + function billPrice(bill: Item) { + const a = bill.attributes; + const value = formatMoney( + billMoney(a, isRange(a) ? average(a) : Number(a.amount_min)) + ); + return `${isRange(a) ? "~" : ""}${value} ${repeats(a)}`; } async function remove() { @@ -253,11 +290,6 @@ } } - async function removeLink(linkId: string) { - await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" }); - await linksQuery.refetch(); - } - function oldUi(path = page.url.pathname) { location.assign(oldUiUrl(new URL(path, location.origin).href)); } @@ -266,66 +298,297 @@ ["withdrawal", "deposit", "transfer"].filter((t) => t !== type) ); - useKeys(() => - group && editable - ? [ - { - group: "Transaction", - keys: "e", - label: "Edit transaction", - run: () => goto(editUrl(id)), - }, - { - group: "Transaction", - keys: "c", - label: "Clone transaction", - run: () => goto(cloneUrl(group)), - }, - { - group: "Transaction", - keys: "d", - label: "Delete transaction (press twice)", - run: () => confirm?.press(), - }, - ] - : [] - ); + useKeys(() => { + if (!(group && actionable)) { + return []; + } + const at = nav.current?.index ?? 0; + return [ + { + group: "Transaction", + keys: "e", + label: "Edit every field", + run: () => goto(editUrl(id)), + }, + ...(type === "reconciliation" + ? [] + : [ + { + group: "Transaction", + keys: "c", + label: "Clone transaction", + run: () => goto(cloneUrl(group)), + }, + ]), + { + group: "Transaction", + keys: "d", + label: "Delete transaction (press twice)", + run: () => confirm?.press(), + }, + ...(suggestions.length + ? [ + { + group: "Transaction", + keys: "L", + label: `Link to the subscription ${suggestions[0].bill.attributes.name}`, + run: link, + }, + ] + : []), + ...PICKS.filter(([, field]) => rowIndex.has(rowKey(field, at))).map( + ([keys, field]) => ({ + group: "Transaction", + keys, + label: `Change the ${FIELD_LABEL[field]}`, + run: () => openField(field, at), + }) + ), + ]; + }); {#snippet money( - value: Signed + value: Signed, + size = "" )} - + {formatMoney(value, value.tone !== "transfer")} {/snippet} -{#snippet accountLink( - accountId: string | null | undefined, - name: string | null | undefined, - type: string | null | undefined +{#snippet account( + split: Split, + side: "source" | "destination" )} - {#if isCash(type)} + {#if isCash(split[`${side}_type`])} (cash) {:else} - {name}{split[`${side}_name`]} {/if} {/snippet} -{#snippet row( - label: string +{#snippet empty( + text: string, + field: FieldName, + index: number )} - - {label} - + {#if rowIndex.has(rowKey(field, index))} + + {:else} + {text} + {/if} +{/snippet} + +{#snippet view( + field: FieldName, + split: Split, + index: number +)} + {#if field === "date"} + {dateLabel(split.date)} + {:else if field === "description"} + {split.description} + {:else if field === "amount"} + {@const foreign = signedForeign(split)} + {@render money(signedAmount(split))} + {#if foreign} + {formatMoney(foreign)} + {/if} + {:else if field === "source" || field === "destination"} + {@render account(split, field)} + {:else if field === "category"} + {#if split.category_id} + category + {split.category_name} + {:else} + {@render empty("no category", field, index)} + {/if} + {:else if field === "budget"} + {#if split.budget_id} + budget + {split.budget_name} + {:else} + {@render empty("no budget", field, index)} + {/if} + {:else if field === "tags"} + {#if split.tags?.length} + {split.tags.length === 1 ? "tag" : "tags"} + {#each split.tags as tag, i (tag)} + {#if i > 0} + , + {/if} + {tag} + {/each} + {:else} + {@render empty("no tags", field, index)} + {/if} + {:else if field === "bill"} + {#if split.bill_id} + subscription + {split.bill_name} + {#if linked.has(journalId(split))} + linked + {/if} + {:else} + {@render empty("no subscription", field, index)} + {/if} + {:else if split.notes} + + {:else} + {@render empty("Add notes", field, index)} + {/if} +{/snippet} + +{#snippet cell( + field: FieldName, + index: number +)} + {@const split = splits[index]} + {@const at = rowIndex.get(rowKey(field, index))} + {#if at !== undefined && editing === rows[at].key} + (editing = null)} + onsave={(patch, all) => save(rows[at], patch, all)} + row={rows[at]} + {split} + type={type as TxType} + /> + {:else} + + {@render view(field, split, index)} + + {/if} +{/snippet} + +{#snippet suggestion( + split: Split +)} + {@const found = suggestions.find((s) => s.journal === journalId(split))} + {#if found} +
    + Looks like the subscription + {found.bill.attributes.name} + ({billPrice(found.bill)}, + {found.paid + ? `already paid ${formatDate(found.paid, "short")}` + : `unpaid for ${formatDate(found.month, "month")}`}). + {#if linking && linking !== "Linking"} + {linking} + {:else} + + {/if} +
    + {/if} +{/snippet} + +{#snippet classes( + split: Split, + index: number +)} + {@const fields = ( + split.type === "withdrawal" + ? ["category", "budget", "tags", "bill"] + : ["category", "tags"] + ) as FieldName[]} +
    + {#each fields as field, i (field)} + + {@render cell(field, index)} + {#if i < fields.length - 1} + · + {/if} + + {/each} +
    + {#if !split.bill_id} + {@render suggestion(split)} + {/if} + {@const dates = META_DATES.filter((key) => split[key])} + {@const refs = REFERENCES.filter(([key]) => split[key])} + {#if dates.length || refs.length || split.external_url || split.recurrence_id} +
    + {#each dates as key (key)} + {META_DATE_LABEL[key].toLowerCase()} + {formatDate(split[key])} + {/each} + {#each refs as [key, label] (key)} + {label} + {split[key]} + {/each} + {#if split.recurrence_id} + recurring, {split.recurrence_count} + of {split.recurrence_total || "∞"} + {/if} + {#if split.external_url} + {split.external_url} + {/if} +
    + {/if} {/snippet} {#snippet actions()} {#if group}
    - {#if editable} + {#if actionable} @@ -349,7 +611,7 @@ {/if} - Delete d + Delete d d {/if} @@ -359,7 +621,7 @@ More - {#if editable && type !== "reconciliation"} + {#if actionable && type !== "reconciliation"} {#each others as target (target)} @@ -399,402 +661,165 @@ {#if groupQuery.isPending}

    Loading transaction...

    -{:else if groupQuery.error || !group} +{:else if groupQuery.error || !group || !first}

    {groupQuery.error?.message ?? "This transaction does not exist."}

    {:else} -
    - - - - - {@render row("ID")} - - - - {@render row("Type")} - - - - {@render row("Description")} - - - - {@render row("Date")} - - - -
    #{id}
    - {TYPE_LABEL[type] ?? type} -
    {title}
    {dateLabel(splits[0]?.date)}
    - {#if deleteError} -

    {deleteError}

    - {/if} -
    - - - - - - {@render row("Source account(s)")} - - - - {@render row("Destination account(s)")} - - - - {@render row("Total amount")} - - - -
    - {#each uniqueAccounts("source") as acc, i (acc.id)} - {#if i > 0} - , - {/if} - {@render accountLink( - acc.id, - acc.name, - acc.cash ? "Cash account" : "" - )} - {/each} -
    - {#each uniqueAccounts("destination") as acc, i (acc.id)} - {#if i > 0} - , - {/if} - {@render accountLink( - acc.id, - acc.name, - acc.cash ? "Cash account" : "" - )} - {/each} -
    - - {#each sumByCurrency(splits) as sum (sum.code)} - {@render money(sum)} - {/each} - {#each foreignTotals as sum (sum.code)} - ({formatMoney(sum)}) - {/each} - -
    -
    -
    - - {#if splits.length > 1} -

    Splits

    - {/if} - -
    - {#each splits as split, index (journalId(split))} - {@const foreign = signedForeign(split)} - {@const files = attachmentsFor(split)} - {@const events = eventsFor(split)} - {@const links = linksQuery.data?.[journalId(split)] ?? []} - {@const usual = usualOf(split, pastQuery.data)} -
    - - {#snippet tools()} -

    +
    +
    +
    + + {TYPE_LABEL[type] ?? type} + · + {@render cell("date", 0)} + {#if splits.some((split) => split.reconciled)} + · + reconciled - {split.description} - {#if split.reconciled} - - {/if} - {#if splits.length > 1} - {index + 1} - / {splits.length}{deleteError} + {/if} +
    + +
    +
    + {#if many} + {title} + {:else} + {@render cell("description", 0)} + {/if} +
    +
    + {#if many} + {#each sumByCurrency(splits) as sum (sum.code)} + {@render money(sum)} + {/each} + {#each foreignTotals as sum (sum.code)} + {formatMoney(sum)} - {/if} -

    - {/snippet} - - - - - - {#if usual} - {@const said = verdict(usual.change)} - - {@render row("Usual here")} - - - {/if} - - {@render row("Transaction journal ID")} - - - - {@render row("Category")} - - - {#if split.type === "withdrawal"} - - {@render row("Budget")} - - - {#if split.bill_id} - - {@render row("Subscription")} - - - {/if} - {/if} - {#each DATE_FIELDS as [field, label] (field)} - {#if split[field]} - - {@render row(label)} - - - {/if} {/each} - {#each META_FIELDS as [field, label] (field)} - {#if split[field]} - - {@render row(label)} - - - {/if} - {/each} - {#if split.external_url} - - {@render row("External URL")} - - - {/if} - {#if split.notes} - - {@render row("Notes")} - - - {/if} - {#if split.recurrence_id} - - {@render row("Recurring transaction")} - - - {/if} - {#if split.tags?.length} - - {@render row("Tags")} - - - {/if} - -
    -
    - {@render accountLink( - split.source_id, - split.source_name, - split.source_type - )} - → - {@render money(signedAmount(split))} - {#if foreign} - ({formatMoney(foreign)}) - {/if} - → - {@render accountLink( - split.destination_id, - split.destination_name, - split.destination_type - )} -
    -
    - - - median of the - last {usual.count} - · - {said.text} - -
    #{journalId(split)}
    - {#if split.category_id} - {split.category_name} - {:else} - (no category) - {/if} -
    - {#if split.budget_id} - {split.budget_name} - {:else} - (no budget) - {/if} -
    - {split.bill_name} -
    {formatDate(split[field])}
    {split[field]}
    - {split.external_url} -
    - {split.notes} -
    - {split.recurrence_count} - of {split.recurrence_total || "∞"} -
    - {#each split.tags as tag, i (tag)} - {#if i > 0} - , - {/if} - {tag} - {/each} -
    -
    + {:else} + {@render cell("amount", 0)} + {/if} +
    +
    - {#if links.length > 0} - -
      - {#each links as link (link.id)} -
    • - {link.phrase} - {#if link.group} - "{link.description}" - {:else} - "{link.description}" - {/if} - {#if link.amount} - ({@render money(link.amount)}) - {/if} - {#if link.notes} - ({link.notes}) - {/if} - removeLink(link.id)} - > - Remove link - -
    • - {/each} -
    -
    - {/if} +
    +
    + {#if many && counterSide === "source"} + {@render account(first, "source")} + {#if splits.some((s) => s.source_id !== first.source_id)} + and others + {/if} + {:else} + {@render cell("source", 0)} + {/if} + → + {#if many && counterSide === "destination"} + {@render account(first, "destination")} + {#if splits.some( + (s) => s.destination_id !== first.destination_id + )} + and others + {/if} + {:else} + {@render cell("destination", 0)} + {/if} +
    + {#if balances.length} +
    + {balances.length > 1 ? "balances after" : "balance after"} + {balances.join(", ")} +
    + {/if} +
    - {#if files.length > 0} - - - - {/if} - - {#if events.length > 0} - -
      - {#each events as piggyEvent (piggyEvent.id)} - {@const piggy = piggyOf(piggyEvent)} -
    • - {#if piggy.id} - {piggy.name} - {:else} - Piggy bank - {/if} - -
    • - {/each} -
    -
    + {#if !many} + {@render classes(first, 0)} {/if}
    - {/each} + + {#if !many} +
    + {@render cell("notes", 0)} +
    + {:else} +
      + {#each splits as split, index (journalId(split))} +
    1. + {index + 1} +
      +
      +
      + {@render cell("description", index)} +
      +
      + {@render cell("amount", index)} +
      +
      + {#if counterSide} +
      + {counterSide === "destination" ? "to" : "from"} + {@render cell(counterSide, index)} +
      + {/if} + {@render classes(split, index)} +
      + {@render cell("notes", index)} +
      +
      +
    2. + {/each} +
    + {/if} + + + + + + +

    + transaction #{id} + {splits.length > 1 ? "journals" : "journal"} + {splits.map((split) => `#${journalId(split)}`).join(", ")} + {#if group.attributes.created_at} + created {formatDateTime(group.attributes.created_at)} + {/if} + {#if group.attributes.updated_at && + group.attributes.updated_at !== group.attributes.created_at} + updated {formatDateTime(group.attributes.updated_at)} + {/if} +

    {/if} diff --git a/src/routes/transactions/show/[id=id]/usual.ts b/src/routes/transactions/show/[id=id]/usual.ts deleted file mode 100644 index d1ba209..0000000 --- a/src/routes/transactions/show/[id=id]/usual.ts +++ /dev/null @@ -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> { - const pages = await Promise.all( - places.map((place) => - api>(`/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 | 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 }; -}