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
@@ -4,6 +4,7 @@
|
||||
import type { One, TransactionGroup } from "#lib/api/types.js";
|
||||
import Editor from "#lib/components/editor/Editor.svelte";
|
||||
import { fromGroup, isEditableType } from "#lib/components/editor/model.js";
|
||||
import Context from "#lib/components/receipt/Context.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -41,6 +42,9 @@
|
||||
>
|
||||
</p>
|
||||
{:else if group.data}
|
||||
<Editor group={group.data.data} initial={fromGroup(group.data.data)} />
|
||||
<div class="flex flex-col gap-3">
|
||||
<Editor group={group.data.data} initial={fromGroup(group.data.data)} />
|
||||
<Context group={group.data.data} />
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,97 +0,0 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import type { Page, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import { addMonths } from "#lib/dates.js";
|
||||
import { isCash } from "#lib/transactions/split.js";
|
||||
|
||||
const COUNT = 10;
|
||||
const MIN_COUNT = 3;
|
||||
const LOOKBACK_MONTHS = 60;
|
||||
const PERCENT = 100;
|
||||
|
||||
export interface Usual {
|
||||
change: number;
|
||||
count: number;
|
||||
median: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const placesOf = (splits: Split[]) => [
|
||||
...new Set(
|
||||
splits
|
||||
.filter(
|
||||
(split) =>
|
||||
split.type === "withdrawal" &&
|
||||
split.destination_id &&
|
||||
!isCash(split.destination_type)
|
||||
)
|
||||
.map((split) => split.destination_id ?? "")
|
||||
),
|
||||
];
|
||||
|
||||
export async function loadPast(
|
||||
places: string[],
|
||||
groupId: string,
|
||||
end: string
|
||||
): Promise<Record<string, Split[]>> {
|
||||
const pages = await Promise.all(
|
||||
places.map((place) =>
|
||||
api<Page<TransactionGroup>>(`/v1/accounts/${place}/transactions`, {
|
||||
query: {
|
||||
end,
|
||||
limit: COUNT + 1,
|
||||
start: addMonths(end, -LOOKBACK_MONTHS),
|
||||
type: "withdrawal",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
return Object.fromEntries(
|
||||
places.map((place, i) => [
|
||||
place,
|
||||
pages[i].data
|
||||
.filter((group) => group.id !== groupId)
|
||||
.flatMap((group) =>
|
||||
group.attributes.transactions.filter(
|
||||
(split) => split.destination_id === place
|
||||
)
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
export function usualOf(
|
||||
split: Split,
|
||||
past: Record<string, Split[]> | undefined
|
||||
): Usual | null {
|
||||
const same = (past?.[split.destination_id ?? ""] ?? [])
|
||||
.filter((item) => item.currency_code === split.currency_code)
|
||||
.slice(0, COUNT);
|
||||
if (same.length < MIN_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const typical = median(same.map((item) => Math.abs(Number(item.amount))));
|
||||
return {
|
||||
change: Math.round((Number(split.amount) / typical - 1) * PERCENT),
|
||||
count: same.length,
|
||||
median: typical,
|
||||
query: `destination_account_id:${split.destination_id} currency_is:${split.currency_code} date_before:${split.date.slice(0, 10)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ABOUT = 5;
|
||||
const NOTABLE = 15;
|
||||
|
||||
export function verdict(change: number) {
|
||||
if (Math.abs(change) < ABOUT) {
|
||||
return { text: "about usual", warn: false };
|
||||
}
|
||||
return change > 0
|
||||
? { text: `+${change}% above usual`, warn: change >= NOTABLE }
|
||||
: { text: `${-change}% below usual`, warn: false };
|
||||
}
|
||||
Reference in new issue
Block a user