feat: tag clean-up converters, saved searches, quick entry subscriptions and foreign amounts
Tag clean-up lists tags named like a category, account or subscription, tags used once and unused ones; s c and s s move a tag's transactions to a category or subscription, Enter again deletes the tag. P pins a search, pins and built-ins show in the palette. Quick entry takes %subscription and =520 UAH. The transaction page compares the amount with the usual one at the same place.
This commit is contained in:
1 parent
6f39343e07
commit
01c53d982d
13 files changed
+1093
-102
No files matched your search
@@ -13,6 +13,7 @@
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { explain, operatorsQuery } from "#lib/transactions/query.js";
|
||||
import { searches } from "#lib/transactions/searches.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
|
||||
let input = $state<HTMLInputElement>();
|
||||
let seconds = $state(0);
|
||||
let pinNote = $state({ query: "", text: "" });
|
||||
|
||||
const query = $derived(page.url.searchParams.get("search")?.trim() ?? "");
|
||||
const pageNo = $derived(
|
||||
@@ -58,6 +60,7 @@
|
||||
}));
|
||||
|
||||
const pagination = $derived(results.data?.meta.pagination);
|
||||
const pinned = $derived(searches.has(query));
|
||||
const explanation = $derived(explain(query, operators.data ?? []));
|
||||
|
||||
function submit(text: string) {
|
||||
@@ -83,6 +86,18 @@
|
||||
focus();
|
||||
}
|
||||
|
||||
function togglePin() {
|
||||
if (!query) {
|
||||
focus();
|
||||
return;
|
||||
}
|
||||
const pin = searches.toggle(query);
|
||||
pinNote = {
|
||||
query,
|
||||
text: pin ? "Pinned, it is in the palette under Searches" : "Unpinned",
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (input && query === "") {
|
||||
input.focus();
|
||||
@@ -91,6 +106,12 @@
|
||||
|
||||
useKeys(() => [
|
||||
{ group: "Global", keys: "/", label: "Focus search", run: focus },
|
||||
{
|
||||
group: "Search",
|
||||
keys: "P",
|
||||
label: pinned ? "Unpin this search" : "Pin this search",
|
||||
run: togglePin,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -118,6 +139,14 @@
|
||||
/>
|
||||
<Button size="sm" type="submit"><Search />Search</Button>
|
||||
{#if query}
|
||||
<Button onclick={togglePin} size="sm" variant="outline">
|
||||
{pinned ? "Unpin" : "Pin"} <span class="kbd max-sm:hidden">P</span>
|
||||
</Button>
|
||||
{#if pinNote.query === query && pinNote.text}
|
||||
<span class="text-[12px] text-cladd-fg-soft" role="status"
|
||||
>{pinNote.text}</span
|
||||
>
|
||||
{/if}
|
||||
<a
|
||||
class="text-[12px] text-brand hover:underline"
|
||||
data-sveltekit-reload
|
||||
@@ -144,6 +173,20 @@
|
||||
{/each}
|
||||
<span>Tab or Enter accepts a suggestion.</span>
|
||||
</p>
|
||||
<p class="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="text-cladd-fg-soft">Saved:</span>
|
||||
{#each searches.list() as saved (saved.query)}
|
||||
<a
|
||||
class="text-brand hover:underline"
|
||||
href="/search?search={encodeURIComponent(saved.query)}"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
searches.open(saved);
|
||||
}}
|
||||
>{saved.label}</a
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
|
||||
|
||||
+224
-26
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||
import type { TransactionGroup } from "#lib/api/types.js";
|
||||
import type { Item, Tag, TransactionGroup } from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||
@@ -12,7 +13,15 @@
|
||||
import { setParam } from "#lib/components/categories/url.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { deleteTag, usage } from "#lib/components/tags/actions.js";
|
||||
import ConvertField from "#lib/components/tags/ConvertField.svelte";
|
||||
import Duplicates from "#lib/components/tags/Duplicates.svelte";
|
||||
import {
|
||||
type Finding,
|
||||
findings,
|
||||
leafOf,
|
||||
rankOf,
|
||||
SECTIONS,
|
||||
} from "#lib/components/tags/findings.js";
|
||||
import MergeField from "#lib/components/tags/MergeField.svelte";
|
||||
import RenameField from "#lib/components/tags/RenameField.svelte";
|
||||
import {
|
||||
@@ -30,6 +39,7 @@
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { signedAmount } from "#lib/transactions/split.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -53,24 +63,57 @@
|
||||
|
||||
const counts = createQuery(() => ({
|
||||
enabled: cleanup && Boolean(tags.data),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () => usage(tags.data ?? []),
|
||||
queryKey: ["tags-usage", (tags.data ?? []).map((t) => t.id).join(",")],
|
||||
}));
|
||||
const categories = createQuery(() => lists.categories());
|
||||
const bills = createQuery(() => lists.bills());
|
||||
const expenses = createQuery(() => ({
|
||||
...lists.accounts("expense"),
|
||||
enabled: cleanup,
|
||||
}));
|
||||
const found = $derived(
|
||||
cleanup
|
||||
? findings(tags.data ?? [], {
|
||||
accounts: expenses.data ?? [],
|
||||
bills: bills.data ?? [],
|
||||
categories: categories.data ?? [],
|
||||
usage: counts.data,
|
||||
})
|
||||
: new Map<string, Finding>()
|
||||
);
|
||||
|
||||
const items = $derived(splitRows(groups.data ?? []));
|
||||
const stats = $derived(tagStats(items, !(flat || filter)));
|
||||
const rank = (node: TagNode) => rankOf(found.get(node.tag?.id ?? ""));
|
||||
const nodes = $derived.by(() => {
|
||||
const source = tags.data ?? [];
|
||||
if (filter) {
|
||||
return flatNodes(
|
||||
fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
|
||||
);
|
||||
const matched = filter
|
||||
? fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
|
||||
: source;
|
||||
if (cleanup) {
|
||||
return flatNodes(matched).sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
return flat ? flatNodes(source) : flatten(buildTree(source));
|
||||
if (filter || flat) {
|
||||
return flatNodes(matched);
|
||||
}
|
||||
return flatten(buildTree(source));
|
||||
});
|
||||
const sectionOf = (i: number) =>
|
||||
found.get(nodes[i]?.tag?.id ?? "")?.section ?? "rest";
|
||||
const sectionSize = $derived(
|
||||
nodes.reduce<Record<string, number>>((sizes, _, i) => {
|
||||
sizes[sectionOf(i)] = (sizes[sectionOf(i)] ?? 0) + 1;
|
||||
return sizes;
|
||||
}, {})
|
||||
);
|
||||
const clusters = $derived(cleanup ? duplicates(tags.data ?? []) : []);
|
||||
|
||||
let editing = $state<{ path: string; mode: "rename" | "merge" } | null>(null);
|
||||
let editing = $state<{
|
||||
path: string;
|
||||
mode: "rename" | "merge" | "category" | "bill";
|
||||
} | null>(null);
|
||||
let armed = $state<string | null>(null);
|
||||
let armTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let rowStatus = $state<Record<string, string>>({});
|
||||
@@ -99,6 +142,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
function convert(node: TagNode | undefined, mode: "category" | "bill") {
|
||||
if (node?.tag) {
|
||||
armed = null;
|
||||
editing = { mode, path: node.path };
|
||||
}
|
||||
}
|
||||
|
||||
function choicesFor(mode: "category" | "bill") {
|
||||
if (mode === "category") {
|
||||
return (categories.data ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.attributes.name,
|
||||
}));
|
||||
}
|
||||
return (bills.data ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
name: b.attributes.name ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
function matches(finding: Finding | undefined) {
|
||||
return [
|
||||
finding?.category && {
|
||||
href: `/categories/show/${finding.category.id}`,
|
||||
kind: "category",
|
||||
name: finding.category.attributes.name,
|
||||
},
|
||||
finding?.account && {
|
||||
href: `/accounts/show/${finding.account.id}`,
|
||||
kind: "account",
|
||||
name: finding.account.attributes.name,
|
||||
},
|
||||
finding?.bill && {
|
||||
href: `/subscriptions/show/${finding.bill.id}`,
|
||||
kind: "subscription",
|
||||
name: finding.bill.attributes.name,
|
||||
},
|
||||
].filter((match) => match !== undefined);
|
||||
}
|
||||
|
||||
function initialFor(tag: Item<Tag>, mode: "category" | "bill") {
|
||||
const finding = found.get(tag.id);
|
||||
const match =
|
||||
mode === "bill"
|
||||
? finding?.bill?.attributes.name
|
||||
: finding?.category?.attributes.name;
|
||||
return match ?? leafOf(tag.attributes.tag);
|
||||
}
|
||||
|
||||
async function remove(node: TagNode) {
|
||||
if (!node.tag) {
|
||||
return;
|
||||
@@ -166,6 +258,18 @@
|
||||
label: "Merge tag into another",
|
||||
run: () => merge(nav.current),
|
||||
},
|
||||
{
|
||||
group: "Tags",
|
||||
keys: "s c",
|
||||
label: "Move tag's transactions to a category",
|
||||
run: () => convert(nav.current, "category"),
|
||||
},
|
||||
{
|
||||
group: "Tags",
|
||||
keys: "s s",
|
||||
label: "Link tag's transactions to a subscription",
|
||||
run: () => convert(nav.current, "bill"),
|
||||
},
|
||||
{
|
||||
group: "Tags",
|
||||
keys: "d d",
|
||||
@@ -205,6 +309,38 @@
|
||||
nav.use();
|
||||
</script>
|
||||
|
||||
{#snippet reason(
|
||||
finding: Finding | undefined
|
||||
)}
|
||||
<span class="flex flex-wrap items-baseline gap-x-2">
|
||||
{#each matches(finding) as match (match.kind)}
|
||||
<span
|
||||
>{match.kind}
|
||||
<a class="text-brand hover:underline" href={match.href}
|
||||
>{match.name}</a
|
||||
></span
|
||||
>
|
||||
{/each}
|
||||
{#if finding?.sample}
|
||||
{@const split = finding.sample.attributes.transactions[0]}
|
||||
{@const money = signedAmount(split)}
|
||||
<a
|
||||
class="max-w-64 truncate text-cladd-fg hover:underline"
|
||||
href="/transactions/show/{finding.sample.id}"
|
||||
>{split.description}</a
|
||||
>
|
||||
<Amount
|
||||
amount={money.amount}
|
||||
code={money.code}
|
||||
decimals={money.decimals}
|
||||
symbol={money.symbol}
|
||||
tone={money.tone}
|
||||
/>
|
||||
<span class="num">{formatDate(split.date.slice(0, 10))}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<PageHeader crumbs={[{ label: "Tags" }]} title="Tags">
|
||||
{#snippet actions()}
|
||||
<Button href="/tags/create" size="sm" variant="outline">
|
||||
@@ -219,19 +355,21 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if cleanup}
|
||||
<Duplicates {clusters} counts={counts.data ?? new Map()} />
|
||||
<Duplicates {clusters} counts={counts.data?.counts ?? new Map()} />
|
||||
{/if}
|
||||
|
||||
<Box body="p-0" title="Tags">
|
||||
{#snippet tools()}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if !(flat || filter)}
|
||||
{#if !(flat || filter || cleanup)}
|
||||
<span class="hidden text-[12px] text-cladd-fg-soft md:inline"
|
||||
>parent rows include sub-tags</span
|
||||
>
|
||||
{/if}
|
||||
<div
|
||||
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px]"
|
||||
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px] {cleanup
|
||||
? "hidden"
|
||||
: ""}"
|
||||
>
|
||||
<button
|
||||
aria-pressed={!flat}
|
||||
@@ -299,8 +437,12 @@
|
||||
{counts.isFetching ? "Counting" : "All time"}
|
||||
</th>
|
||||
{/if}
|
||||
<th class="hidden px-3 font-medium md:table-cell">Date</th>
|
||||
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
|
||||
{#if cleanup}
|
||||
<th class="px-3 font-medium">Reason</th>
|
||||
{:else}
|
||||
<th class="hidden px-3 font-medium md:table-cell">Date</th>
|
||||
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<ListMenu
|
||||
@@ -323,6 +465,16 @@
|
||||
label: "Merge into another tag",
|
||||
run: () => merge(nav.current),
|
||||
},
|
||||
{
|
||||
keys: "s c",
|
||||
label: "Move to a category",
|
||||
run: () => convert(nav.current, "category"),
|
||||
},
|
||||
{
|
||||
keys: "s s",
|
||||
label: "Link to a subscription",
|
||||
run: () => convert(nav.current, "bill"),
|
||||
},
|
||||
{
|
||||
label: "Open in old UI",
|
||||
run: () =>
|
||||
@@ -341,8 +493,24 @@
|
||||
{#each nodes as node, i (node.path)}
|
||||
{@const stat = stats.get(node.path)}
|
||||
{@const total = node.tag
|
||||
? counts.data?.get(node.tag.id)
|
||||
? counts.data?.counts.get(node.tag.id)
|
||||
: undefined}
|
||||
{@const finding = found.get(node.tag?.id ?? "")}
|
||||
{#if cleanup && (i === 0 || sectionOf(i - 1) !== sectionOf(i))}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-b bg-cladd-surface-next"
|
||||
>
|
||||
<td
|
||||
class="px-3 font-semibold text-[12px] text-cladd-fg-soft"
|
||||
colspan="7"
|
||||
>
|
||||
{SECTIONS[sectionOf(i)]}
|
||||
<span class="num ml-1 font-normal"
|
||||
>{sectionSize[sectionOf(i)]}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||
i
|
||||
@@ -371,6 +539,22 @@
|
||||
tag={node.tag}
|
||||
tags={tags.data ?? []}
|
||||
/>
|
||||
{:else if editing?.path === node.path &&
|
||||
(editing.mode === "category" ||
|
||||
editing.mode === "bill") &&
|
||||
node.tag}
|
||||
<ConvertField
|
||||
choices={choicesFor(editing.mode)}
|
||||
initial={initialFor(node.tag, editing.mode)}
|
||||
kind={editing.mode}
|
||||
ondone={(status) => {
|
||||
editing = null;
|
||||
if (status) {
|
||||
rowStatus[node.path] = status;
|
||||
}
|
||||
}}
|
||||
tag={node.tag}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
class="hover:underline {node.tag
|
||||
@@ -419,18 +603,24 @@
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
<td
|
||||
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
|
||||
>
|
||||
{node.tag?.attributes.date
|
||||
? formatDate(node.tag.attributes.date)
|
||||
: ""}
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
|
||||
>
|
||||
{node.tag?.attributes.description ?? ""}
|
||||
</td>
|
||||
{#if cleanup}
|
||||
<td class="px-3 text-[12px] text-cladd-fg-soft">
|
||||
{@render reason(finding)}
|
||||
</td>
|
||||
{:else}
|
||||
<td
|
||||
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
|
||||
>
|
||||
{node.tag?.attributes.date
|
||||
? formatDate(node.tag.attributes.date)
|
||||
: ""}
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
|
||||
>
|
||||
{node.tag?.attributes.description ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{:else}
|
||||
{#if !tags.isPending}
|
||||
@@ -465,6 +655,14 @@
|
||||
>
|
||||
<span><span class="kbd">r</span> rename</span>
|
||||
<span><span class="kbd">m</span> merge</span>
|
||||
<span
|
||||
><span class="kbd">s</span><span class="kbd">c</span>
|
||||
to category</span
|
||||
>
|
||||
<span
|
||||
><span class="kbd">s</span><span class="kbd">s</span>
|
||||
to subscription</span
|
||||
>
|
||||
<span
|
||||
><span class="kbd">d</span><span class="kbd">d</span>
|
||||
delete</span
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
} 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;
|
||||
@@ -166,6 +167,14 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -495,6 +504,7 @@
|
||||
{@const files = attachmentsFor(split)}
|
||||
{@const events = eventsFor(split)}
|
||||
{@const links = linksQuery.data?.[journalId(split)] ?? []}
|
||||
{@const usual = usualOf(split, pastQuery.data)}
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<Box body="p-0">
|
||||
{#snippet tools()}
|
||||
@@ -539,6 +549,35 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if usual}
|
||||
{@const said = verdict(usual.change)}
|
||||
<tr>
|
||||
{@render row("Usual here")}
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<Amount
|
||||
amount={-usual.median}
|
||||
code={split.currency_code}
|
||||
decimals={split.currency_decimal_places}
|
||||
symbol={split.currency_symbol}
|
||||
/>
|
||||
<span class="text-cladd-fg-soft">median of the</span>
|
||||
<a
|
||||
class="text-brand hover:underline"
|
||||
href="/search?search={encodeURIComponent(usual.query)}"
|
||||
>last {usual.count}</a
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap {said.warn
|
||||
? "text-warn"
|
||||
: "text-cladd-fg-soft"}"
|
||||
><span class="text-cladd-fg-softer">·</span>
|
||||
{said.text}</span
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr>
|
||||
{@render row("Transaction journal ID")}
|
||||
<td class="num px-3 py-1.5">#{journalId(split)}</td>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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