feat(budgets): top line per currency, sorting places into budgets, projections
Budgets show budgeted, left, spent and spent without a budget per currency, safe per day and where the month ends. t sorts expenses without a budget by place, 1 to 9 move a whole place into a budget, u undoes, and the editor and quick entry remember the place. A suggests empty amounts from three months. Categories get the same sorting.
This commit is contained in:
1 parent
68f64893f7
commit
cea51281cd
12 files changed
+1535
-214
No files matched your search
@@ -0,0 +1,28 @@
|
||||
const KEY = "ffui.budget-map";
|
||||
|
||||
export interface Learned {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function read(): Record<string, Learned> {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
||||
return value && typeof value === "object" ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const learnedBudget = (destinationId: string): Learned | null =>
|
||||
read()[destinationId] ?? null;
|
||||
|
||||
export function learnBudget(destinationId: string, budget: Learned | null) {
|
||||
const map = read();
|
||||
if (budget) {
|
||||
map[destinationId] = { id: budget.id, name: budget.name };
|
||||
} else {
|
||||
delete map[destinationId];
|
||||
}
|
||||
localStorage.setItem(KEY, JSON.stringify(map));
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, queryClient } from "#lib/api/lists.js";
|
||||
import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import {
|
||||
add,
|
||||
curOfSplit,
|
||||
list,
|
||||
type Sum,
|
||||
type Sums,
|
||||
} from "#lib/components/categories/sums.js";
|
||||
import type { Range } from "#lib/dates.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
import {
|
||||
type Patch,
|
||||
type Targets,
|
||||
updateGroups,
|
||||
} from "#lib/transactions/mass.js";
|
||||
import { journalId } from "#lib/transactions/split.js";
|
||||
|
||||
const IN_FLIGHT = 6;
|
||||
const SEARCH_LIMIT = 500;
|
||||
const SAMPLES = 3;
|
||||
|
||||
export type Scope = "period" | "all";
|
||||
|
||||
export interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
group: Item<TransactionGroup>;
|
||||
key: string;
|
||||
split: Split;
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
accountId: string;
|
||||
entries: Entry[];
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export const withoutBudget = (range: Range | null) =>
|
||||
all<TransactionGroup>("/v1/budgets/transactions-without-budget", {
|
||||
type: "withdrawal",
|
||||
...range,
|
||||
});
|
||||
|
||||
async function searchFrom(
|
||||
query: string,
|
||||
page: number
|
||||
): Promise<Item<TransactionGroup>[]> {
|
||||
const result = await api<Page<TransactionGroup>>("/v1/search/transactions", {
|
||||
query: { limit: SEARCH_LIMIT, page, query },
|
||||
});
|
||||
return result.data.length < SEARCH_LIMIT
|
||||
? result.data
|
||||
: [...result.data, ...(await searchFrom(query, page + 1))];
|
||||
}
|
||||
|
||||
export async function withoutCategory(range: Range | null) {
|
||||
const dates = range
|
||||
? ` date_after:${range.start} date_before:${range.end}`
|
||||
: "";
|
||||
const groups = await searchFrom(`has_no_category:true${dates}`, 1);
|
||||
return [...new Map(groups.map((group) => [group.id, group])).values()];
|
||||
}
|
||||
|
||||
const byDate = (a: Entry, b: Entry) => b.split.date.localeCompare(a.split.date);
|
||||
|
||||
export function placesOf(
|
||||
groups: Item<TransactionGroup>[],
|
||||
keep: (split: Split) => boolean
|
||||
): Place[] {
|
||||
const places = new Map<string, Place>();
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions.filter(keep)) {
|
||||
const deposit = split.type === "deposit";
|
||||
const accountId =
|
||||
(deposit ? split.source_id : split.destination_id) ?? "";
|
||||
const key = `${split.type}:${accountId}`;
|
||||
const place = places.get(key) ?? {
|
||||
accountId,
|
||||
entries: [],
|
||||
key,
|
||||
name: (deposit ? split.source_name : split.destination_name) ?? "",
|
||||
type: split.type,
|
||||
};
|
||||
place.entries.push({ group, key: journalId(split), split });
|
||||
places.set(key, place);
|
||||
}
|
||||
}
|
||||
return [...places.values()]
|
||||
.map((place) => ({ ...place, entries: place.entries.sort(byDate) }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.entries.length - a.entries.length || a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export function sumsOf(entries: Entry[]): Sum[] {
|
||||
const sums: Sums = {};
|
||||
for (const { split } of entries) {
|
||||
const sign = split.type === "withdrawal" ? -1 : 1;
|
||||
add(sums, curOfSplit(split), sign * toNumber(split.amount));
|
||||
}
|
||||
return list(sums);
|
||||
}
|
||||
|
||||
export const samplesOf = (entries: Entry[]) =>
|
||||
[...new Set(entries.map((entry) => entry.split.description))].slice(
|
||||
0,
|
||||
SAMPLES
|
||||
);
|
||||
|
||||
export function targetsOf(entries: Entry[]): Targets {
|
||||
const targets: Targets = new Map();
|
||||
for (const { group, key } of entries) {
|
||||
const set = targets.get(group.id) ?? new Set<string>();
|
||||
set.add(key);
|
||||
targets.set(group.id, set);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
let running = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
async function slot<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (running < IN_FLIGHT) {
|
||||
running += 1;
|
||||
} else {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
}
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
const next = waiting.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
running -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPatch(
|
||||
targets: Targets,
|
||||
patch: Patch,
|
||||
progress: (done: number) => void
|
||||
): Promise<string[]> {
|
||||
let done = 0;
|
||||
const errors: string[] = [];
|
||||
await Promise.all(
|
||||
[...targets].map((target) =>
|
||||
slot(async () => {
|
||||
const outcome = await updateGroups(
|
||||
new Map([target]),
|
||||
() => patch,
|
||||
() => undefined
|
||||
);
|
||||
errors.push(...outcome.errors);
|
||||
done += 1;
|
||||
progress(done);
|
||||
})
|
||||
)
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export const refreshExcept = (key: unknown[]) => {
|
||||
const own = JSON.stringify(key);
|
||||
return queryClient.invalidateQueries({
|
||||
predicate: (query) => JSON.stringify(query.queryKey) !== own,
|
||||
});
|
||||
};
|
||||
|
||||
export function storedSet(key: string) {
|
||||
return {
|
||||
read(): Set<string> {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) ?? "[]");
|
||||
return new Set(Array.isArray(value) ? value : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
},
|
||||
write(set: Set<string>) {
|
||||
localStorage.setItem(key, JSON.stringify([...set]));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
budgetId,
|
||||
range,
|
||||
editing,
|
||||
suggested = null,
|
||||
trap = true,
|
||||
onedit,
|
||||
ondone,
|
||||
@@ -21,6 +22,7 @@
|
||||
budgetId: string;
|
||||
range: Range;
|
||||
editing: boolean;
|
||||
suggested?: number | null;
|
||||
trap?: boolean;
|
||||
onedit: () => void;
|
||||
ondone: (move: Move) => void;
|
||||
@@ -33,7 +35,7 @@
|
||||
$effect(() => {
|
||||
if (editing) {
|
||||
untrack(() => {
|
||||
value = line.amount ? String(line.amount) : "";
|
||||
value = String(line.amount || suggested || "");
|
||||
problem = "";
|
||||
});
|
||||
}
|
||||
@@ -123,6 +125,20 @@
|
||||
{@attach focus}
|
||||
>
|
||||
</span>
|
||||
{:else if suggested && line.amount === null}
|
||||
<button
|
||||
class="num border-brand border-b border-dashed font-semibold leading-tight text-brand"
|
||||
onclick={onedit}
|
||||
title="Suggested from the last 3 months (e to change)"
|
||||
type="button"
|
||||
>
|
||||
<span class="font-normal text-[11px]">suggested</span>
|
||||
{formatMoney({
|
||||
amount: suggested,
|
||||
decimals: line.cur.decimals,
|
||||
symbol: line.cur.symbol,
|
||||
})}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
|
||||
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { periodPath } from "./period-path.svelte.js";
|
||||
import { keptParams, periodPath } from "./period-path.svelte.js";
|
||||
|
||||
const COUNT = 12;
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
|
||||
const before = $derived(walk(-1));
|
||||
const after = $derived(walk(1));
|
||||
const href = (range: Range) => periodPath(base, range.start, range.end);
|
||||
const kept = $derived(keptParams());
|
||||
const href = (range: Range) =>
|
||||
`${periodPath(base, range.start, range.end)}${kept}`;
|
||||
|
||||
function pick(event: Event) {
|
||||
const select = event.currentTarget as HTMLSelectElement;
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { amountIn, type Sum } from "#lib/components/categories/sums.js";
|
||||
import { tones } from "#lib/components/charts/chart.js";
|
||||
import Meter from "#lib/components/charts/Meter.svelte";
|
||||
import { formatMoney, formatRange, percent } from "#lib/format.js";
|
||||
import type { Pool } from "./data.js";
|
||||
|
||||
let {
|
||||
pool,
|
||||
total,
|
||||
noBudget,
|
||||
noBudgetCount,
|
||||
noBudgetHref,
|
||||
}: {
|
||||
pool: Pool;
|
||||
total: Sum[];
|
||||
noBudget: Sum[];
|
||||
noBudgetCount: number;
|
||||
noBudgetHref: string;
|
||||
} = $props();
|
||||
|
||||
let explain = $state(false);
|
||||
|
||||
const money = (amount: number) =>
|
||||
formatMoney({
|
||||
amount,
|
||||
decimals: pool.cur.decimals,
|
||||
symbol: pool.cur.symbol,
|
||||
});
|
||||
|
||||
const spent = $derived(-pool.spent);
|
||||
const left = $derived(pool.budgeted - spent);
|
||||
const allSpent = $derived(-amountIn(total, pool.cur.code));
|
||||
const outside = $derived(-amountIn(noBudget, pool.cur.code));
|
||||
const available = $derived(pool.available ?? 0);
|
||||
const planned = $derived(pool.available !== null || pool.budgeted > 0);
|
||||
|
||||
const budgetedSegments = $derived(
|
||||
pool.budgeted > available && available > 0
|
||||
? [
|
||||
{ color: tones.transfer, value: available },
|
||||
{ color: tones.expense, value: pool.budgeted - available },
|
||||
]
|
||||
: [{ color: tones.transfer, value: pool.budgeted }]
|
||||
);
|
||||
const spentSegments = $derived(
|
||||
spent > pool.budgeted && pool.budgeted > 0
|
||||
? [
|
||||
{ color: tones.warn, value: pool.budgeted },
|
||||
{ color: tones.expense, value: spent - pool.budgeted },
|
||||
]
|
||||
: [{ color: "var(--ff-brand)", value: spent }]
|
||||
);
|
||||
</script>
|
||||
|
||||
<Box
|
||||
body="flex flex-col gap-3 p-3"
|
||||
title="Total available budget in {pool.cur.code}"
|
||||
>
|
||||
{#snippet tools()}
|
||||
{#if pool.availableRange}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
>{formatRange(pool.availableRange.start, pool.availableRange.end)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if !planned}
|
||||
<p class="text-[13px] text-cladd-fg-soft">
|
||||
Nothing budgeted in {pool.cur.code} for this period. Budgets spent
|
||||
<span class="num text-expense">{money(spent)}</span>
|
||||
in {pool.cur.code}.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 text-[13px]"
|
||||
>
|
||||
<span class="text-cladd-fg-soft">Available</span>
|
||||
<button
|
||||
class="num justify-self-end border-cladd-fg-softest border-b border-dashed text-right font-semibold text-cladd-fg"
|
||||
onclick={() => (explain = !explain)}
|
||||
type="button"
|
||||
>
|
||||
{pool.available === null ? "not set" : money(available)}
|
||||
</button>
|
||||
<span class="text-[12px] text-cladd-fg-soft">
|
||||
{#if explain}
|
||||
Firefly works this out from the amounts budgeted below. It can't be
|
||||
edited here or in the API.
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="text-cladd-fg-soft">Budgeted</span>
|
||||
<span class="num justify-self-end font-semibold text-cladd-fg"
|
||||
>{money(pool.budgeted)}</span
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div class="min-w-16 flex-1 basis-24">
|
||||
<Meter max={available || pool.budgeted} segments={budgetedSegments} />
|
||||
</div>
|
||||
{#if available > 0}
|
||||
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||
>{percent(pool.budgeted / available)}
|
||||
of available</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="text-cladd-fg-soft">Spent</span>
|
||||
<span class="num justify-self-end font-semibold text-expense"
|
||||
>{money(spent)}</span
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div class="min-w-16 flex-1 basis-24">
|
||||
<Meter max={pool.budgeted || spent} segments={spentSegments} />
|
||||
</div>
|
||||
{#if pool.budgeted > 0}
|
||||
<span
|
||||
class="num shrink-0 text-[12px] {left < 0
|
||||
? "text-expense"
|
||||
: "text-cladd-fg-soft"}"
|
||||
>{left < 0
|
||||
? `${money(-left)} overspent`
|
||||
: `${money(left)} left`}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if allSpent > 0}
|
||||
<p
|
||||
class="mt-auto border-cladd-outline border-t pt-2 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
All expenses in {pool.cur.code}:
|
||||
<span class="num text-cladd-fg">{money(allSpent)}</span>.
|
||||
{#if outside > 0}
|
||||
<a class="text-brand hover:underline" href={noBudgetHref}
|
||||
><span class="num">{money(outside)}</span>
|
||||
of it has no budget{noBudgetCount
|
||||
? ` (${noBudgetCount} transactions in all currencies)`
|
||||
: ""}</a
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { formatMoney, percent } from "#lib/format.js";
|
||||
import type { Top } from "./data.js";
|
||||
|
||||
const CENT = 0.005;
|
||||
|
||||
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
|
||||
|
||||
const free = $derived(
|
||||
tops.some(
|
||||
(top) =>
|
||||
top.available !== null && Math.abs(top.available - top.budgeted) > CENT
|
||||
)
|
||||
);
|
||||
const columns = $derived(
|
||||
[
|
||||
free && "Available",
|
||||
"Budgeted",
|
||||
free && "Left to budget",
|
||||
"Left in budgets",
|
||||
"Spent",
|
||||
"Without a budget",
|
||||
].filter(Boolean) as string[]
|
||||
);
|
||||
|
||||
const money = (top: Top, amount: number) =>
|
||||
formatMoney({ amount, decimals: 0, symbol: top.cur.symbol });
|
||||
</script>
|
||||
|
||||
<Box body="px-3 py-3">
|
||||
<div
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-[3rem_repeat(var(--cols),minmax(0,1fr))] sm:gap-y-2"
|
||||
style:--cols={columns.length}
|
||||
>
|
||||
<span class="max-sm:hidden"></span>
|
||||
{#each columns as label (label)}
|
||||
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden">{label}</span>
|
||||
{/each}
|
||||
{#each tops as top, i (top.cur.code)}
|
||||
<span
|
||||
class="col-span-2 self-center font-semibold text-[13px] text-cladd-fg-soft sm:col-span-1 {i >
|
||||
0
|
||||
? "max-sm:mt-1 max-sm:border-cladd-outline max-sm:border-t max-sm:pt-3"
|
||||
: ""}"
|
||||
>{top.cur.code}</span
|
||||
>
|
||||
{#if free}
|
||||
{@render stat(
|
||||
"Available",
|
||||
top.available === null ? null : money(top, top.available)
|
||||
)}
|
||||
{/if}
|
||||
{@render stat("Budgeted", top.budgeted ? money(top, top.budgeted) : null)}
|
||||
{#if free}
|
||||
{@const unallocated = (top.available ?? 0) - top.budgeted}
|
||||
{@render stat(
|
||||
"Left to budget",
|
||||
top.available === null ? null : money(top, unallocated),
|
||||
unallocated < 0 ? "text-expense" : ""
|
||||
)}
|
||||
{/if}
|
||||
{@const left = top.budgeted - top.inBudgets}
|
||||
{@render stat(
|
||||
"Left in budgets",
|
||||
top.budgeted ? money(top, left) : null,
|
||||
left < 0 ? "text-expense" : ""
|
||||
)}
|
||||
{@render stat("Spent", money(top, top.spent))}
|
||||
<div class="min-w-0">
|
||||
<span class="text-[12px] text-cladd-fg-soft sm:hidden"
|
||||
>Without a budget</span
|
||||
>
|
||||
{#if top.outside > 0}
|
||||
<button
|
||||
class="block text-left hover:underline"
|
||||
onclick={onsort}
|
||||
title="Sort them into budgets t"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="num font-semibold text-[20px] text-brand tracking-[-0.02em] sm:text-[22px]"
|
||||
>{money(top, top.outside)}</span
|
||||
>
|
||||
{#if top.spent > 0}
|
||||
<span class="num ml-1 text-[13px] text-cladd-fg-soft"
|
||||
>{percent(top.outside / top.spent)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="num block font-semibold text-[20px] text-cladd-fg-softer sm:text-[22px]"
|
||||
>0</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{#snippet stat(
|
||||
label: string,
|
||||
value: string | null,
|
||||
tone = ""
|
||||
)}
|
||||
<div class="min-w-0">
|
||||
<span class="text-[12px] text-cladd-fg-soft sm:hidden">{label}</span>
|
||||
<span
|
||||
class="num block font-semibold text-[20px] tracking-[-0.02em] sm:text-[22px] {value ===
|
||||
null
|
||||
? "text-cladd-fg-softer"
|
||||
: tone || "text-cladd-fg"}"
|
||||
>{value ?? "0"}</span
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -0,0 +1,673 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
applyPatch,
|
||||
type Entry,
|
||||
type Option,
|
||||
type Place,
|
||||
samplesOf,
|
||||
storedSet,
|
||||
sumsOf,
|
||||
targetsOf,
|
||||
} from "#lib/budgets/triage.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||
import AutocompleteInput, {
|
||||
type Option as Choice,
|
||||
} from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import type { Patch, Targets } from "#lib/transactions/mass.js";
|
||||
import { showUrl } from "#lib/transactions/split.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const DIGITS = 9;
|
||||
const REFRESH_MS = 800;
|
||||
|
||||
interface Row {
|
||||
entry?: Entry;
|
||||
key: string;
|
||||
place: Place;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
done: number;
|
||||
entries: string[];
|
||||
error: string;
|
||||
forget?: () => void;
|
||||
kind: "move" | "ignore";
|
||||
label: string;
|
||||
row: string;
|
||||
state: "busy" | "undoing" | "done";
|
||||
targets: Targets;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
noun,
|
||||
places,
|
||||
loading,
|
||||
problem = "",
|
||||
empty,
|
||||
options,
|
||||
pickKey,
|
||||
active,
|
||||
everything,
|
||||
periodLabel,
|
||||
ignoreKey,
|
||||
assignPatch,
|
||||
clearPatch,
|
||||
create,
|
||||
onassign,
|
||||
hint,
|
||||
onswitch,
|
||||
onactivate,
|
||||
onleave,
|
||||
onchange,
|
||||
}: {
|
||||
title: string;
|
||||
noun: string;
|
||||
places: Place[];
|
||||
loading: boolean;
|
||||
problem?: string;
|
||||
empty: string;
|
||||
options: Option[];
|
||||
pickKey: string;
|
||||
active: boolean;
|
||||
everything: boolean;
|
||||
periodLabel: string;
|
||||
ignoreKey: string;
|
||||
assignPatch: (id: string) => Patch;
|
||||
clearPatch: Patch;
|
||||
create: (name: string) => Promise<Option>;
|
||||
onassign?: (place: Place, option: Option) => () => void;
|
||||
hint?: (place: Place) => string | null;
|
||||
onswitch: () => void;
|
||||
onactivate: () => void;
|
||||
onleave: () => void;
|
||||
onchange: () => void;
|
||||
} = $props();
|
||||
|
||||
const store = untrack(() => storedSet(ignoreKey));
|
||||
let ignored = $state(store.read());
|
||||
let expanded = $state(new Set<string>());
|
||||
let actions = $state<Action[]>([]);
|
||||
let picking = $state<string | null>(null);
|
||||
let pickText = $state("");
|
||||
let pickRef = $state<HTMLInputElement | null>(null);
|
||||
let pickError = $state("");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const taken = $derived(new Set(actions.flatMap((action) => action.entries)));
|
||||
const latest = (key: string) =>
|
||||
[...actions].reverse().find((action) => action.row === key);
|
||||
const skipped = (place: Place) =>
|
||||
actions.some(
|
||||
(action) => action.kind === "ignore" && action.row === place.key
|
||||
);
|
||||
const left = (place: Place) =>
|
||||
place.entries.filter((entry) => !taken.has(entry.key));
|
||||
|
||||
const shown = $derived(
|
||||
places.filter((place) => !ignored.has(place.key) || skipped(place))
|
||||
);
|
||||
const rows = $derived<Row[]>(
|
||||
shown.flatMap((place) => [
|
||||
{ key: place.key, place },
|
||||
...(expanded.has(place.key)
|
||||
? place.entries.map((entry) => ({ entry, key: entry.key, place }))
|
||||
: []),
|
||||
])
|
||||
);
|
||||
const open = $derived(
|
||||
shown.filter((place) => !skipped(place) && left(place).length > 0)
|
||||
);
|
||||
const openCount = $derived(
|
||||
open.reduce((sum, place) => sum + left(place).length, 0)
|
||||
);
|
||||
const hidden = $derived(
|
||||
places.filter((place) => ignored.has(place.key) && !skipped(place)).length
|
||||
);
|
||||
const numbered = $derived(options.slice(0, DIGITS));
|
||||
|
||||
function settled(row: Row): boolean {
|
||||
if (row.entry) {
|
||||
return taken.has(row.entry.key) || skipped(row.place);
|
||||
}
|
||||
return skipped(row.place) || left(row.place).length === 0;
|
||||
}
|
||||
|
||||
const indexOf = (key: string) => rows.findIndex((row) => row.key === key);
|
||||
const firstOpen = () =>
|
||||
Math.max(
|
||||
0,
|
||||
rows.findIndex((row) => !settled(row))
|
||||
);
|
||||
|
||||
function nextOpen(from: Row): Row | undefined {
|
||||
const after = rows.slice(indexOf(from.key) + 1);
|
||||
return after.find(
|
||||
(row) =>
|
||||
!settled(row) &&
|
||||
(row.entry === undefined || (from.entry && row.place === from.place))
|
||||
);
|
||||
}
|
||||
|
||||
function collapse(key: string) {
|
||||
if (expanded.has(key)) {
|
||||
const next = new Set(expanded);
|
||||
next.delete(key);
|
||||
expanded = next;
|
||||
}
|
||||
}
|
||||
|
||||
function changed() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(onchange, REFRESH_MS);
|
||||
}
|
||||
|
||||
function record(action: Action, from: Row) {
|
||||
actions = [...actions, action];
|
||||
picking = null;
|
||||
const next = nextOpen(from);
|
||||
if (!from.entry) {
|
||||
collapse(from.place.key);
|
||||
}
|
||||
if (next) {
|
||||
nav.move(indexOf(next.key));
|
||||
}
|
||||
return actions.at(-1) as Action;
|
||||
}
|
||||
|
||||
async function assign(row: Row, option: Option) {
|
||||
if (settled(row)) {
|
||||
return;
|
||||
}
|
||||
const entries = row.entry ? [row.entry] : left(row.place);
|
||||
const targets = targetsOf(entries);
|
||||
const action = record(
|
||||
{
|
||||
done: 0,
|
||||
entries: entries.map((entry) => entry.key),
|
||||
error: "",
|
||||
forget: row.entry ? undefined : onassign?.(row.place, option),
|
||||
kind: "move",
|
||||
label: option.name,
|
||||
row: row.key,
|
||||
state: "busy",
|
||||
targets,
|
||||
},
|
||||
row
|
||||
);
|
||||
const errors = await applyPatch(targets, assignPatch(option.id), (done) => {
|
||||
action.done = done;
|
||||
});
|
||||
action.state = "done";
|
||||
action.error = errors[0] ?? "";
|
||||
changed();
|
||||
}
|
||||
|
||||
function ignore(row: Row) {
|
||||
const { place } = row;
|
||||
if (skipped(place)) {
|
||||
return;
|
||||
}
|
||||
ignored = new Set([...ignored, place.key]);
|
||||
store.write(ignored);
|
||||
record(
|
||||
{
|
||||
done: 0,
|
||||
entries: [],
|
||||
error: "",
|
||||
kind: "ignore",
|
||||
label: "",
|
||||
row: place.key,
|
||||
state: "done",
|
||||
targets: new Map(),
|
||||
},
|
||||
{ key: place.key, place }
|
||||
);
|
||||
}
|
||||
|
||||
async function undo(action: Action | undefined) {
|
||||
if (action?.state !== "done") {
|
||||
return;
|
||||
}
|
||||
const at = indexOf(action.row);
|
||||
if (at >= 0) {
|
||||
nav.move(at);
|
||||
}
|
||||
if (action.kind === "ignore") {
|
||||
const next = new Set(ignored);
|
||||
next.delete(action.row);
|
||||
ignored = next;
|
||||
store.write(next);
|
||||
actions = actions.filter((item) => item !== action);
|
||||
return;
|
||||
}
|
||||
action.state = "undoing";
|
||||
action.done = 0;
|
||||
const errors = await applyPatch(action.targets, clearPatch, (done) => {
|
||||
action.done = done;
|
||||
});
|
||||
action.forget?.();
|
||||
if (errors.length > 0) {
|
||||
action.state = "done";
|
||||
action.error = errors[0];
|
||||
} else {
|
||||
actions = actions.filter((item) => item !== action);
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
const undoLast = () =>
|
||||
undo([...actions].reverse().find((action) => action.state === "done"));
|
||||
|
||||
let shownScope = untrack(() => everything);
|
||||
$effect(() => {
|
||||
if (everything !== shownScope) {
|
||||
shownScope = everything;
|
||||
untrack(() => {
|
||||
nav.cursor = -1;
|
||||
expanded = new Set();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function toggle(row: Row) {
|
||||
const { key } = row.place;
|
||||
const next = new Set(expanded);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
expanded = next;
|
||||
nav.move(indexOf(row.entry && !next.has(key) ? key : row.key));
|
||||
}
|
||||
|
||||
function startPick(row: Row) {
|
||||
if (settled(row)) {
|
||||
return;
|
||||
}
|
||||
pickText = "";
|
||||
pickError = "";
|
||||
picking = row.key;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
pickRef?.focus();
|
||||
});
|
||||
|
||||
async function picked(row: Row, choice: Choice) {
|
||||
if (picking !== row.key) {
|
||||
return;
|
||||
}
|
||||
if (choice.id) {
|
||||
assign(row, { id: choice.id, name: choice.label });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assign(row, await create(choice.label));
|
||||
} catch (failure) {
|
||||
pickError = failure instanceof Error ? failure.message : String(failure);
|
||||
}
|
||||
}
|
||||
|
||||
const search = (query: string): Choice[] =>
|
||||
fuzzyFilter(options, query, (option) => option.name, 40).map((option) => ({
|
||||
id: option.id,
|
||||
label: option.name,
|
||||
}));
|
||||
|
||||
function show(row: Row) {
|
||||
if (row.entry) {
|
||||
goto(showUrl(row.entry.group.id));
|
||||
} else if (row.place.accountId) {
|
||||
goto(`/accounts/show/${row.place.accountId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (action: (row: Row) => void) => () => {
|
||||
const row = nav.current;
|
||||
if (row) {
|
||||
action(row);
|
||||
}
|
||||
};
|
||||
|
||||
const group = $derived(
|
||||
`Sort into ${noun === "budget" ? "budgets" : "categories"}`
|
||||
);
|
||||
|
||||
function bindings(): Binding[] {
|
||||
return [
|
||||
...numbered.map((option, i) => ({
|
||||
group,
|
||||
keys: String(i + 1),
|
||||
label: `Move to ${option.name}`,
|
||||
run: run((row) => assign(row, option)),
|
||||
})),
|
||||
{
|
||||
group,
|
||||
keys: pickKey,
|
||||
label: `Move to another ${noun}`,
|
||||
run: run(startPick),
|
||||
},
|
||||
{ group, keys: "n", label: "Ignore this place", run: run(ignore) },
|
||||
{ group, keys: "u", label: "Undo the last move", run: undoLast },
|
||||
{
|
||||
group,
|
||||
keys: "Enter",
|
||||
label: "Show or hide the transactions",
|
||||
run: run(toggle),
|
||||
},
|
||||
{ group, keys: "o", label: "Open", run: run(show) },
|
||||
{
|
||||
group,
|
||||
keys: "v",
|
||||
label: `Switch between ${periodLabel} and everything`,
|
||||
run: onswitch,
|
||||
},
|
||||
{ group, keys: "Escape", label: "Stop sorting", run: onleave },
|
||||
];
|
||||
}
|
||||
|
||||
const nav: ListNav<Row> = new ListNav<Row>({
|
||||
extra: bindings,
|
||||
id: "triage",
|
||||
items: () => rows,
|
||||
key: (row) => row.key,
|
||||
});
|
||||
useKeys(() => (active ? nav.bindings() : []));
|
||||
|
||||
const ready = $derived(rows.length > 0);
|
||||
const lost = $derived(nav.cursor < 0 || nav.cursor >= rows.length);
|
||||
|
||||
$effect(() => {
|
||||
if (active && ready && lost) {
|
||||
untrack(() => nav.move(firstOpen()));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (active) {
|
||||
untrack(() => nav.reveal("nearest"));
|
||||
}
|
||||
});
|
||||
|
||||
function click(index: number, event: MouseEvent) {
|
||||
nav.click(index, event);
|
||||
onactivate();
|
||||
}
|
||||
|
||||
function unignore() {
|
||||
ignored = new Set();
|
||||
store.write(ignored);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Box
|
||||
body="p-0"
|
||||
class="mt-4"
|
||||
footer={hidden > 0 ? ignoredNote : undefined}
|
||||
{title}
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 border-cladd-outline border-b px-3 py-2"
|
||||
>
|
||||
<p class="text-[14px] text-cladd-fg">
|
||||
{#if loading}
|
||||
<span class="text-cladd-fg-soft">Loading</span>
|
||||
{:else if openCount === 0}
|
||||
{empty}
|
||||
{:else}
|
||||
<span class="num font-semibold">{openCount}</span>
|
||||
{openCount === 1 ? "transaction" : "transactions"}
|
||||
in <span class="num font-semibold">{open.length}</span>
|
||||
{open.length === 1 ? "place" : "places"}
|
||||
{noun === "budget" ? "have no budget" : "have no category"}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<fieldset
|
||||
aria-label="Which transactions"
|
||||
class="cladd-cut flex h-7 items-center gap-0.5 rounded-cladd-sm p-0.5"
|
||||
>
|
||||
{#each [false, true] as all (all)}
|
||||
<button
|
||||
aria-pressed={everything === all}
|
||||
class="h-6 rounded-[5px] px-2 text-[12px] {everything === all
|
||||
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => everything !== all && onswitch()}
|
||||
title="Switch with v"
|
||||
type="button"
|
||||
>
|
||||
{all ? "Everything" : periodLabel}
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{#if openCount > 0}
|
||||
{#if active}
|
||||
<Button onclick={onleave} size="sm" variant="outline">
|
||||
Stop sorting <span class="kbd">Esc</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={onactivate} size="sm">
|
||||
Sort them <span class="kbd">t</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if problem}
|
||||
<p class="px-3 py-3 text-expense">{problem}</p>
|
||||
{:else if rows.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr
|
||||
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
<th class="px-3 font-medium sm:w-[26%]">Place</th>
|
||||
<th class="px-3 text-right font-medium sm:w-16">Count</th>
|
||||
<th class="px-3 text-right font-medium sm:w-[14%]">Amount</th>
|
||||
<th class="px-3 font-medium max-md:hidden">For example</th>
|
||||
<th class="w-[22%] px-3 max-sm:hidden"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row, i (row.key)}
|
||||
{@const done = settled(row)}
|
||||
{@const cursor = active && nav.cursor === i}
|
||||
<tr
|
||||
class="h-8 scroll-mb-14 border-cladd-outline border-b {cursor
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"} {done ? "text-cladd-fg-softer" : ""}"
|
||||
id={nav.rowId(i)}
|
||||
onclick={(event) => click(i, event)}
|
||||
>
|
||||
{#if row.entry}
|
||||
{@const split = row.entry.split}
|
||||
<td class="py-1 pr-3 pl-8">
|
||||
<span class={done ? "" : "text-cladd-fg"}
|
||||
>{split.description}</span
|
||||
>
|
||||
{@render result(row, "sm:hidden")}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-cladd-fg-soft">
|
||||
{formatDate(split.date, "short")}
|
||||
</td>
|
||||
<td class="px-3 text-right">
|
||||
<Amount
|
||||
amount={split.type === "withdrawal"
|
||||
? -Number(split.amount)
|
||||
: split.amount}
|
||||
decimals={split.currency_decimal_places}
|
||||
symbol={split.currency_symbol}
|
||||
tone={done ? "neutral" : "auto"}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="max-w-0 truncate px-3 text-cladd-fg-soft max-md:hidden"
|
||||
>
|
||||
{split.type === "withdrawal" ? "from" : "to"}
|
||||
{split.type === "withdrawal"
|
||||
? split.source_name
|
||||
: split.destination_name}
|
||||
</td>
|
||||
{:else}
|
||||
{@const rest = done ? row.place.entries : left(row.place)}
|
||||
<td class="py-1 pr-3 pl-3">
|
||||
<span class="font-medium {done ? "" : "text-cladd-fg"}"
|
||||
>{row.place.name || "(no name)"}</span
|
||||
>
|
||||
{@render result(row, "sm:hidden")}
|
||||
</td>
|
||||
<td class="num px-3 text-right">{rest.length}</td>
|
||||
<td class="px-3 text-right">
|
||||
<MoneyList
|
||||
sums={sumsOf(rest)}
|
||||
tone={done ? "neutral" : "auto"}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="max-w-0 truncate px-3 text-cladd-fg-soft max-md:hidden"
|
||||
>
|
||||
{samplesOf(rest).join(", ")}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="px-3 max-sm:hidden">{@render result(row, "")}</td>
|
||||
</tr>
|
||||
{#if cursor && !done}
|
||||
<tr class="border-cladd-outline border-b bg-row-cursor/40">
|
||||
<td class="px-3 py-2" colspan="5">
|
||||
{@render strip(row)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Box>
|
||||
|
||||
{#snippet ignoredNote()}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>{hidden}
|
||||
{hidden === 1 ? "place is" : "places are"}
|
||||
ignored.</span
|
||||
>
|
||||
<button class="text-brand hover:underline" onclick={unignore} type="button">
|
||||
Show them again
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet result(
|
||||
row: Row,
|
||||
extra: string
|
||||
)}
|
||||
{@const item = latest(row.key)}
|
||||
{#if item}
|
||||
<div class="flex flex-wrap items-center gap-x-2 text-[12px] {extra}">
|
||||
{#if item.state !== "done"}
|
||||
<span class="num text-cladd-fg-soft"
|
||||
>{item.state === "undoing"
|
||||
? "Putting back"
|
||||
: `Moving to ${item.label}`}
|
||||
{item.done}
|
||||
of {item.targets.size}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-cladd-fg">
|
||||
{item.kind === "ignore"
|
||||
? "Ignored"
|
||||
: `${item.entries.length} moved to ${item.label}`}
|
||||
</span>
|
||||
{#if item.error}
|
||||
<span class="text-expense">{item.error}</span>
|
||||
{/if}
|
||||
<button
|
||||
class="text-brand hover:underline"
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
undo(item);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !row.entry && hint?.(row.place)}
|
||||
<div class="text-[12px] text-cladd-fg-soft {extra}">
|
||||
Usually {hint(row.place)}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet strip(
|
||||
row: Row
|
||||
)}
|
||||
{@const count = row.entry ? 1 : left(row.place).length}
|
||||
{#if picking === row.key}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AutocompleteInput
|
||||
aria-label="Pick a {noun}"
|
||||
class="w-72 max-w-full"
|
||||
free
|
||||
onblur={() => (picking = null)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
picking = null;
|
||||
nav.move(nav.cursor);
|
||||
}
|
||||
}}
|
||||
onpick={(choice) => picked(row, choice)}
|
||||
openOnFocus
|
||||
placeholder="Type a {noun} name"
|
||||
{search}
|
||||
bind:ref={pickRef}
|
||||
bind:value={pickText}
|
||||
/>
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
><span class="kbd">↵</span>
|
||||
moves, a new name creates the {noun}</span
|
||||
>
|
||||
{#if pickError}
|
||||
<span class="text-[12px] text-expense">{pickError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="mr-1 text-cladd-fg-soft"
|
||||
>Move {count === 1 ? "it" : `all ${count}`} to</span
|
||||
>
|
||||
{#each numbered as option, n (option.id)}
|
||||
<Button onclick={() => assign(row, option)} size="sm" variant="outline">
|
||||
<span class="kbd">{n + 1}</span>{option.name}
|
||||
</Button>
|
||||
{/each}
|
||||
<Button onclick={() => startPick(row)} size="sm" variant="outline">
|
||||
<span class="kbd">{pickKey}</span>Another {noun}
|
||||
</Button>
|
||||
{#if !row.entry}
|
||||
<Button onclick={() => ignore(row)} size="sm" variant="ghost">
|
||||
<span class="kbd">n</span>Ignore
|
||||
</Button>
|
||||
<Button onclick={() => toggle(row)} size="sm" variant="ghost">
|
||||
<span class="kbd">↵</span>
|
||||
{expanded.has(row.place.key) ? "Hide" : "Show"}
|
||||
{count === 1 ? "it" : "them"}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -7,18 +7,27 @@ import type {
|
||||
Currency,
|
||||
InsightTotalEntry,
|
||||
Item,
|
||||
Page,
|
||||
TransactionGroup,
|
||||
Split,
|
||||
} from "#lib/api/types.js";
|
||||
import { placesOf, sumsOf, withoutBudget } from "#lib/budgets/triage.js";
|
||||
import {
|
||||
add,
|
||||
amountIn,
|
||||
type Cur,
|
||||
fromEntries,
|
||||
list,
|
||||
type Sum,
|
||||
type Sums,
|
||||
} from "#lib/components/categories/sums.js";
|
||||
import { contains, daysBetween, type Range, today } from "#lib/dates.js";
|
||||
import {
|
||||
contains,
|
||||
daysBetween,
|
||||
isDynamic,
|
||||
type Range,
|
||||
shiftPeriod,
|
||||
today,
|
||||
type ViewRange,
|
||||
} from "#lib/dates.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
export interface Line {
|
||||
@@ -43,6 +52,15 @@ export interface Pool {
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface Top {
|
||||
available: number | null;
|
||||
budgeted: number;
|
||||
cur: Cur;
|
||||
inBudgets: number;
|
||||
outside: number;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface Pace {
|
||||
expected: number;
|
||||
fraction: number;
|
||||
@@ -58,6 +76,9 @@ export interface BudgetPage {
|
||||
total: Sum[];
|
||||
}
|
||||
|
||||
export const isOpen = (split: Split) =>
|
||||
split.type === "withdrawal" && !split.budget_id;
|
||||
|
||||
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
|
||||
|
||||
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
|
||||
@@ -98,23 +119,20 @@ export async function loadBudgetPage(
|
||||
currencies: Item<Currency>[]
|
||||
): Promise<BudgetPage> {
|
||||
const query = { end: range.end, start: range.start };
|
||||
const [budgets, limits, available, total, noBudget, noBudgetPage] =
|
||||
await Promise.all([
|
||||
all<Budget>("/v1/budgets", query),
|
||||
all<BudgetLimit>("/v1/budget-limits", query),
|
||||
all<AvailableBudget>("/v1/available-budgets", query),
|
||||
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
|
||||
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
|
||||
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
|
||||
query: { ...query, limit: 1, type: "withdrawal" },
|
||||
}),
|
||||
]);
|
||||
const [budgets, limits, available, total, groups] = await Promise.all([
|
||||
all<Budget>("/v1/budgets", query),
|
||||
all<BudgetLimit>("/v1/budget-limits", query),
|
||||
all<AvailableBudget>("/v1/available-budgets", query),
|
||||
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
|
||||
withoutBudget(range),
|
||||
]);
|
||||
const entries = placesOf(groups, isOpen).flatMap((place) => place.entries);
|
||||
return {
|
||||
available,
|
||||
budgets,
|
||||
limits,
|
||||
noBudget: insightSums(noBudget, currencies),
|
||||
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
|
||||
noBudget: sumsOf(entries),
|
||||
noBudgetCount: entries.length,
|
||||
total: insightSums(total, currencies),
|
||||
};
|
||||
}
|
||||
@@ -215,6 +233,36 @@ export function buildPools(
|
||||
return result.length > 0 ? result : [pool(primary)];
|
||||
}
|
||||
|
||||
export function topLines(
|
||||
pools: Pool[],
|
||||
total: Sum[],
|
||||
noBudget: Sum[],
|
||||
primary: string
|
||||
): Top[] {
|
||||
const curs = new Map<string, Cur>();
|
||||
for (const item of [...pools.map((pool) => pool.cur), ...total]) {
|
||||
curs.set(item.code, curs.get(item.code) ?? item);
|
||||
}
|
||||
return [...curs.values()]
|
||||
.map((cur) => {
|
||||
const pool = pools.find((item) => item.cur.code === cur.code);
|
||||
return {
|
||||
available: pool?.available ?? null,
|
||||
budgeted: pool?.budgeted ?? 0,
|
||||
cur,
|
||||
inBudgets: -(pool?.spent ?? 0),
|
||||
outside: -amountIn(noBudget, cur.code),
|
||||
spent: -amountIn(total, cur.code),
|
||||
};
|
||||
})
|
||||
.filter((top) => top.budgeted || top.spent || top.available)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.cur.code === primary) - Number(a.cur.code === primary) ||
|
||||
b.spent - a.spent
|
||||
);
|
||||
}
|
||||
|
||||
export function pace(line: Line, now = today()): Pace | null {
|
||||
if (!(line.amount && contains(line.range, now))) {
|
||||
return null;
|
||||
@@ -225,16 +273,60 @@ export function pace(line: Line, now = today()): Pace | null {
|
||||
return { expected, fraction: elapsed / total, over: -line.spent - expected };
|
||||
}
|
||||
|
||||
export function perDay(line: Line, now = today()) {
|
||||
export function projection(line: Line, now = today()): number | null {
|
||||
if (!contains(line.range, now)) {
|
||||
return null;
|
||||
}
|
||||
const total = daysBetween(line.range.start, line.range.end) + 1;
|
||||
const current = contains(line.range, now);
|
||||
const passed = current ? daysBetween(line.range.start, now) + 1 : total;
|
||||
const left = current ? daysBetween(now, line.range.end) + 1 : total;
|
||||
return {
|
||||
left: line.amount === null ? null : (line.amount + line.spent) / left,
|
||||
past: line.range.end < now,
|
||||
spent: line.spent / passed,
|
||||
};
|
||||
const elapsed = daysBetween(line.range.start, now) + 1;
|
||||
return (-line.spent * total) / elapsed;
|
||||
}
|
||||
|
||||
export function safePerDay(line: Line, now = today()): number | null {
|
||||
if (line.amount === null || !contains(line.range, now)) {
|
||||
return null;
|
||||
}
|
||||
const left = daysBetween(now, line.range.end) + 1;
|
||||
return Math.max(0, line.amount + line.spent) / left;
|
||||
}
|
||||
|
||||
const AVERAGE_OF = 3;
|
||||
|
||||
export function niceCeil(value: number) {
|
||||
const step = 10 ** Math.max(0, Math.floor(Math.log10(value)) - 1);
|
||||
return Math.ceil(value / step) * step;
|
||||
}
|
||||
|
||||
export async function averages(
|
||||
range: Range,
|
||||
view: ViewRange
|
||||
): Promise<Map<string, number>> {
|
||||
const fixed = isDynamic(view) ? "1M" : view;
|
||||
const ranges: Range[] = [];
|
||||
let step = range;
|
||||
for (let i = 0; i < AVERAGE_OF; i += 1) {
|
||||
step = shiftPeriod(step, fixed, -1);
|
||||
ranges.push(step);
|
||||
}
|
||||
const periods = await Promise.all(
|
||||
ranges.map((item) =>
|
||||
all<Budget>("/v1/budgets", { end: item.end, start: item.start })
|
||||
)
|
||||
);
|
||||
const sums = new Map<string, number>();
|
||||
for (const budget of periods.flat()) {
|
||||
for (const sum of fromEntries(budget.attributes.spent)) {
|
||||
const key = `${budget.id}:${sum.code}`;
|
||||
sums.set(key, (sums.get(key) ?? 0) - sum.amount / AVERAGE_OF);
|
||||
}
|
||||
}
|
||||
const out = new Map<string, number>();
|
||||
for (const [key, value] of sums) {
|
||||
if (value > 0) {
|
||||
out.set(key, niceCeil(value));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SPACES = /\s/g;
|
||||
|
||||
@@ -7,6 +7,14 @@ export function periodPath(base: string, start: string, end: string) {
|
||||
return `${base}/${start}/${end}?start=${start}&end=${end}`;
|
||||
}
|
||||
|
||||
export function keptParams() {
|
||||
const params = new URLSearchParams(page.url.search);
|
||||
params.delete("start");
|
||||
params.delete("end");
|
||||
const rest = params.toString();
|
||||
return rest ? `&${rest}` : "";
|
||||
}
|
||||
|
||||
export function usePeriodPath(base: () => string) {
|
||||
$effect(() => {
|
||||
const { start, end } = page.params;
|
||||
@@ -25,7 +33,9 @@ export function usePeriodPath(base: () => string) {
|
||||
const target = base();
|
||||
setTimeout(() => {
|
||||
if (page.url.pathname.startsWith(`${target}/${from}`)) {
|
||||
goto(periodPath(target, start, end), { replace: true });
|
||||
goto(`${periodPath(target, start, end)}${keptParams()}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Page,
|
||||
TransactionGroup,
|
||||
} from "#lib/api/types.js";
|
||||
import { learnedBudget } from "#lib/budgets/map.js";
|
||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
@@ -234,7 +235,23 @@ export class Catalog {
|
||||
.catch(() => null);
|
||||
this.history.set(key, found);
|
||||
}
|
||||
return found;
|
||||
const learned = learnedBudget(accountId)?.id;
|
||||
const budget = this.budgets.data?.find(
|
||||
(item) => item.id === learned && item.attributes.active
|
||||
);
|
||||
if (!budget || type !== "withdrawal" || side !== "destination") {
|
||||
return found;
|
||||
}
|
||||
return found.then(
|
||||
(past) =>
|
||||
past && {
|
||||
split: {
|
||||
...past.split,
|
||||
budget_id: budget.id,
|
||||
budget_name: budget.attributes.name,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async fromJournal(journalId: string): Promise<Recent | null> {
|
||||
|
||||
@@ -3,38 +3,56 @@
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import type { Budget, One } from "#lib/api/types.js";
|
||||
import { learnBudget, learnedBudget } from "#lib/budgets/map.js";
|
||||
import {
|
||||
type Place,
|
||||
placesOf,
|
||||
refreshExcept,
|
||||
type Scope,
|
||||
withoutBudget,
|
||||
} from "#lib/budgets/triage.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import {
|
||||
averages,
|
||||
type BudgetRow,
|
||||
buildPools,
|
||||
buildRows,
|
||||
curOf,
|
||||
isOpen,
|
||||
type Line,
|
||||
loadBudgetPage,
|
||||
pace,
|
||||
perDay,
|
||||
projection,
|
||||
safePerDay,
|
||||
saveLimit,
|
||||
topLines,
|
||||
} from "#lib/components/budgets/data.js";
|
||||
import LimitCell, {
|
||||
type Move,
|
||||
} from "#lib/components/budgets/LimitCell.svelte";
|
||||
import MeterBar from "#lib/components/budgets/MeterBar.svelte";
|
||||
import PeriodNav from "#lib/components/budgets/PeriodNav.svelte";
|
||||
import PoolBox from "#lib/components/budgets/PoolBox.svelte";
|
||||
import {
|
||||
periodPath,
|
||||
usePeriodPath,
|
||||
} from "#lib/components/budgets/period-path.svelte.js";
|
||||
import TopLine from "#lib/components/budgets/TopLine.svelte";
|
||||
import Triage from "#lib/components/budgets/Triage.svelte";
|
||||
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||
import { setParam } from "#lib/components/categories/url.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { formatMoney, formatRange } from "#lib/format.js";
|
||||
import type { Binding } from "#lib/keys.svelte.js";
|
||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const PERIOD_WORDS: Record<string, string> = {
|
||||
daily: "a day",
|
||||
@@ -59,8 +77,15 @@
|
||||
? buildRows(query.data, period.range, session.currencies, primary)
|
||||
: []
|
||||
);
|
||||
const pools = $derived(
|
||||
query.data ? buildPools(query.data, rows, session.currencies, primary) : []
|
||||
const tops = $derived(
|
||||
query.data
|
||||
? topLines(
|
||||
buildPools(query.data, rows, session.currencies, primary),
|
||||
query.data.total,
|
||||
query.data.noBudget,
|
||||
primary
|
||||
)
|
||||
: []
|
||||
);
|
||||
const inactive = $derived(
|
||||
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
|
||||
@@ -72,6 +97,33 @@
|
||||
session.currencies.filter((c) => c.attributes.enabled)
|
||||
);
|
||||
|
||||
const scope = $derived<Scope>(
|
||||
page.url.searchParams.get("scope") === "all" ? "all" : "period"
|
||||
);
|
||||
const triageKey = $derived(
|
||||
scope === "all"
|
||||
? ["budget-triage", "all"]
|
||||
: ["budget-triage", period.start, period.end]
|
||||
);
|
||||
const triage = createQuery(() => ({
|
||||
enabled: period.ready,
|
||||
queryFn: () => withoutBudget(scope === "all" ? null : period.range),
|
||||
queryKey: triageKey,
|
||||
}));
|
||||
const places = $derived(triage.data ? placesOf(triage.data, isOpen) : []);
|
||||
const options = $derived(
|
||||
rows.map((row) => ({ id: row.budget.id, name: row.budget.attributes.name }))
|
||||
);
|
||||
const nowCurrent = $derived(period.isCurrent);
|
||||
const endsLabel = $derived(
|
||||
period.view === "1M" ? "Ends month at" : "Ends period at"
|
||||
);
|
||||
|
||||
let triaging = $state(false);
|
||||
let suggested = $state<Map<string, number> | null>(null);
|
||||
let suggestNote = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
let editKey = $state<string | null>(null);
|
||||
let adding = $state<{ budgetId: string; code: string } | null>(null);
|
||||
let newName = $state("");
|
||||
@@ -114,6 +166,102 @@
|
||||
};
|
||||
}
|
||||
|
||||
const suggestionFor = (row: BudgetRow, line: Line) =>
|
||||
line.amount === null
|
||||
? (suggested?.get(`${row.budget.id}:${line.cur.code}`) ?? null)
|
||||
: null;
|
||||
|
||||
function linesOf(row: BudgetRow): Line[] {
|
||||
const extra = addingLine(row);
|
||||
const lines = extra ? [...row.lines, extra] : row.lines;
|
||||
const prefix = `${row.budget.id}:`;
|
||||
const more = [...(suggested?.keys() ?? [])]
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length))
|
||||
.filter((code) => !lines.some((line) => line.cur.code === code))
|
||||
.map((code) => ({
|
||||
amount: null,
|
||||
cur: curOf(session.currencies, code),
|
||||
key: `${prefix}${code}`,
|
||||
range: period.range,
|
||||
spent: 0,
|
||||
}));
|
||||
return [...lines, ...more];
|
||||
}
|
||||
|
||||
const pending = $derived(
|
||||
rows.flatMap((row) =>
|
||||
linesOf(row).flatMap((line) => {
|
||||
const amount = suggestionFor(row, line);
|
||||
return amount ? [{ amount, line, row }] : [];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
async function suggest() {
|
||||
suggestNote = "Looking at the last 3 months";
|
||||
try {
|
||||
suggested = await averages(period.range, period.view);
|
||||
suggestNote =
|
||||
pending.length > 0
|
||||
? ""
|
||||
: "Every budget with spending already has an amount";
|
||||
} catch (failure) {
|
||||
suggestNote = failure instanceof Error ? failure.message : "Failed";
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
suggested = null;
|
||||
suggestNote = "";
|
||||
}
|
||||
|
||||
async function saveSuggested() {
|
||||
if (saving || pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
const items = pending;
|
||||
saving = true;
|
||||
let saved = 0;
|
||||
try {
|
||||
await items.reduce(
|
||||
(chain, item) =>
|
||||
chain.then(async () => {
|
||||
await saveLimit(
|
||||
item.row.budget.id,
|
||||
item.line,
|
||||
item.amount,
|
||||
period.range
|
||||
);
|
||||
saved += 1;
|
||||
suggestNote = `Saving ${saved} of ${items.length}`;
|
||||
}),
|
||||
Promise.resolve()
|
||||
);
|
||||
await invalidate();
|
||||
suggested = null;
|
||||
suggestNote = `Saved ${saved} ${saved === 1 ? "amount" : "amounts"}`;
|
||||
} catch (failure) {
|
||||
suggestNote = failure instanceof Error ? failure.message : "Failed";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBudget(name: string) {
|
||||
const made = await api<One<Budget>>("/v1/budgets", {
|
||||
body: { name },
|
||||
method: "POST",
|
||||
});
|
||||
return { id: made.data.id, name: made.data.attributes.name };
|
||||
}
|
||||
|
||||
function remember(place: Place, option: { id: string; name: string }) {
|
||||
const before = learnedBudget(place.accountId);
|
||||
learnBudget(place.accountId, option);
|
||||
return () => learnBudget(place.accountId, before);
|
||||
}
|
||||
|
||||
function done(index: number, move: Move) {
|
||||
adding = null;
|
||||
const next = rows[index + move];
|
||||
@@ -191,7 +339,38 @@
|
||||
key: (row) => row.budget.id,
|
||||
open: (row) => goto(showHref(row)),
|
||||
});
|
||||
nav.use();
|
||||
|
||||
useKeys(() => [
|
||||
...(triaging ? [] : nav.bindings()),
|
||||
{
|
||||
group: "Budgets",
|
||||
keys: "t",
|
||||
label: triaging ? "Stop sorting" : "Sort expenses without a budget",
|
||||
run: () => (triaging = !triaging),
|
||||
},
|
||||
{
|
||||
group: "Budgets",
|
||||
keys: "A",
|
||||
label: "Suggest empty amounts from the last 3 months",
|
||||
run: suggest,
|
||||
},
|
||||
...(pending.length > 0 && !triaging
|
||||
? [
|
||||
{
|
||||
group: "Budgets",
|
||||
keys: "mod+Enter",
|
||||
label: "Save the suggested amounts",
|
||||
run: saveSuggested,
|
||||
},
|
||||
{
|
||||
group: "Budgets",
|
||||
keys: "Escape",
|
||||
label: "Discard the suggested amounts",
|
||||
run: discard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const money = (line: Line, amount: number) =>
|
||||
formatMoney({
|
||||
@@ -215,26 +394,31 @@
|
||||
<p class="text-expense">Could not load budgets: {query.error.message}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 {pools.length > 1 ? "xl:grid-cols-2" : ""}">
|
||||
{#each pools as pool (pool.cur.code)}
|
||||
<PoolBox
|
||||
noBudget={query.data?.noBudget ?? []}
|
||||
noBudgetCount={query.data?.noBudgetCount ?? 0}
|
||||
{noBudgetHref}
|
||||
{pool}
|
||||
total={query.data?.total ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<TopLine onsort={() => (triaging = true)} {tops} />
|
||||
|
||||
<Box body="p-0" class="mt-4" title="Budgets">
|
||||
{#snippet tools()}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
><span class="kbd">e</span>
|
||||
set amount
|
||||
<span class="kbd">Tab</span>
|
||||
next</span
|
||||
>
|
||||
{#if pending.length > 0}
|
||||
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||
>{pending.length}
|
||||
suggested from the last 3 months</span
|
||||
>
|
||||
<Button disabled={saving} onclick={saveSuggested} size="sm">
|
||||
Save {pending.length === 1 ? "it" : `all ${pending.length}`}
|
||||
</Button>
|
||||
<Button onclick={discard} size="sm" variant="ghost">Discard</Button>
|
||||
{:else if suggestNote}
|
||||
<span aria-live="polite" class="text-[12px] text-cladd-fg-soft"
|
||||
>{suggestNote}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||
><span class="kbd">e</span>
|
||||
set amount
|
||||
<span class="kbd">A</span>
|
||||
suggest from the last 3 months</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-[13px] sm:min-w-[46rem]">
|
||||
@@ -246,7 +430,15 @@
|
||||
<th class="px-3 text-right font-medium">Budgeted</th>
|
||||
<th class="px-3 text-right font-medium">Spent</th>
|
||||
<th class="px-3 text-right font-medium max-sm:hidden">Left</th>
|
||||
<th class="w-[30%] px-3 font-medium max-sm:hidden">Progress</th>
|
||||
{#if nowCurrent}
|
||||
<th class="px-3 text-right font-medium max-sm:hidden">Safe/day</th>
|
||||
<th
|
||||
class="whitespace-nowrap px-3 text-right font-medium max-md:hidden"
|
||||
>
|
||||
{endsLabel}
|
||||
</th>
|
||||
{/if}
|
||||
<th class="w-[24%] px-3 font-medium max-sm:hidden">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<ListMenu
|
||||
@@ -292,18 +484,22 @@
|
||||
<tbody {...props}>
|
||||
{#each rows as row, i (row.budget.id)}
|
||||
{@const extra = addingLine(row)}
|
||||
{@const lines = extra ? [...row.lines, extra] : row.lines}
|
||||
{@const lines = linesOf(row)}
|
||||
{#each lines as line, j (line.key)}
|
||||
{@const p = pace(line)}
|
||||
{@const day = perDay(line)}
|
||||
{@const safe = safePerDay(line)}
|
||||
{@const ends = projection(line)}
|
||||
<tr
|
||||
class="h-8 border-cladd-outline {j === lines.length - 1
|
||||
? "border-b"
|
||||
: ""} {nav.cursor === i
|
||||
: ""} {nav.cursor === i && !triaging
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"}"
|
||||
id={j === 0 ? nav.rowId(i) : undefined}
|
||||
onclick={(event) => nav.click(i, event)}
|
||||
onclick={(event) => {
|
||||
triaging = false;
|
||||
nav.click(i, event);
|
||||
}}
|
||||
oncontextmenu={() => (nav.cursor = i)}
|
||||
>
|
||||
{#if j === 0}
|
||||
@@ -397,6 +593,7 @@
|
||||
editKey = line.key;
|
||||
}}
|
||||
range={period.range}
|
||||
suggested={suggestionFor(row, line)}
|
||||
trap={line !== extra}
|
||||
/>
|
||||
</div>
|
||||
@@ -418,13 +615,28 @@
|
||||
: "text-cladd-fg"}"
|
||||
>{money(line, line.amount + line.spent)}</span
|
||||
>
|
||||
{#if !day.past && day.left !== null && day.left > 0}
|
||||
<span class="num ml-1 text-[11px] text-cladd-fg-soft"
|
||||
>{money(line, day.left)}/day</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
{#if nowCurrent}
|
||||
<td class="num px-3 text-right max-sm:hidden">
|
||||
{#if safe !== null}
|
||||
<span
|
||||
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
|
||||
>{money(line, safe)}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right max-md:hidden">
|
||||
{#if ends && line.spent !== 0}
|
||||
<span
|
||||
class={line.amount && ends > line.amount
|
||||
? "text-expense"
|
||||
: "text-cladd-fg-soft"}
|
||||
>~{money(line, ends)}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="px-3 max-sm:hidden">
|
||||
<div class="flex items-center gap-2.5">
|
||||
{#if line.amount}
|
||||
@@ -468,7 +680,7 @@
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
<MoneyList sums={query.data?.noBudget ?? []} />
|
||||
</td>
|
||||
<td colspan="2"></td>
|
||||
<td colspan={nowCurrent ? 4 : 2}></td>
|
||||
</tr>
|
||||
<tr
|
||||
class="border-cladd-outline border-t bg-cladd-surface-next/40 sm:hidden"
|
||||
@@ -506,6 +718,32 @@
|
||||
{/snippet}
|
||||
</Box>
|
||||
|
||||
<Triage
|
||||
active={triaging}
|
||||
assignPatch={(id) => ({ budget_id: id })}
|
||||
clearPatch={{ budget_id: 0 }}
|
||||
create={createBudget}
|
||||
empty={scope === "all"
|
||||
? "Every expense has a budget."
|
||||
: `Every expense in ${period.label} has a budget.`}
|
||||
everything={scope === "all"}
|
||||
hint={(place) => learnedBudget(place.accountId)?.name ?? null}
|
||||
ignoreKey="ffui.budget-ignored"
|
||||
loading={triage.isPending}
|
||||
noun="budget"
|
||||
onactivate={() => (triaging = true)}
|
||||
onassign={remember}
|
||||
onchange={() => refreshExcept(triageKey)}
|
||||
onleave={() => (triaging = false)}
|
||||
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
||||
{options}
|
||||
periodLabel={period.label}
|
||||
pickKey="b"
|
||||
{places}
|
||||
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
|
||||
title="Expenses without a budget"
|
||||
/>
|
||||
|
||||
{#snippet noBudgetLabel()}
|
||||
<a class="text-brand italic hover:underline" href={noBudgetHref}
|
||||
>Expenses without a budget</a
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import type { Category, One } from "#lib/api/types.js";
|
||||
import {
|
||||
placesOf,
|
||||
refreshExcept,
|
||||
withoutCategory,
|
||||
} from "#lib/budgets/triage.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||
import Triage from "#lib/components/budgets/Triage.svelte";
|
||||
import {
|
||||
type CategoryRow,
|
||||
loadCategoryPage,
|
||||
@@ -18,7 +25,7 @@
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { formatDate, percent } from "#lib/format.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import type { Binding } from "#lib/keys.svelte.js";
|
||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
@@ -98,6 +105,43 @@
|
||||
periodPath("/categories/list/no-category", period.start, period.end)
|
||||
);
|
||||
|
||||
const everything = $derived(page.url.searchParams.get("scope") === "all");
|
||||
const triageKey = $derived(
|
||||
everything
|
||||
? ["category-triage", "all"]
|
||||
: ["category-triage", period.start, period.end]
|
||||
);
|
||||
const triage = createQuery(() => ({
|
||||
enabled: period.ready,
|
||||
queryFn: () => withoutCategory(everything ? null : period.range),
|
||||
queryKey: triageKey,
|
||||
}));
|
||||
const places = $derived(
|
||||
triage.data
|
||||
? placesOf(
|
||||
triage.data,
|
||||
(split) =>
|
||||
(split.type === "withdrawal" || split.type === "deposit") &&
|
||||
!split.category_id
|
||||
)
|
||||
: []
|
||||
);
|
||||
const options = $derived(
|
||||
(query.data?.rows ?? [])
|
||||
.map((row) => ({ id: row.item.id, name: row.item.attributes.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
let triaging = $state(false);
|
||||
|
||||
async function createCategory(name: string) {
|
||||
const made = await api<One<Category>>("/v1/categories", {
|
||||
body: { name },
|
||||
method: "POST",
|
||||
});
|
||||
return { id: made.data.id, name: made.data.attributes.name };
|
||||
}
|
||||
|
||||
let filterInput = $state<HTMLInputElement>();
|
||||
let createInput = $state<HTMLInputElement>();
|
||||
let newName = $state("");
|
||||
@@ -163,7 +207,16 @@
|
||||
key: (row) => row.item.id,
|
||||
open: (row) => goto(showHref(row)),
|
||||
});
|
||||
nav.use();
|
||||
|
||||
useKeys(() => [
|
||||
...(triaging ? [] : nav.bindings()),
|
||||
{
|
||||
group: "Categories",
|
||||
keys: "t",
|
||||
label: triaging ? "Stop sorting" : "Sort transactions without a category",
|
||||
run: () => (triaging = !triaging),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
|
||||
@@ -285,11 +338,14 @@
|
||||
{#each rows as row, i (row.item.id)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||
i
|
||||
i && !triaging
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"}"
|
||||
id={nav.rowId(i)}
|
||||
onclick={(event) => nav.click(i, event)}
|
||||
onclick={(event) => {
|
||||
triaging = false;
|
||||
nav.click(i, event);
|
||||
}}
|
||||
oncontextmenu={() => (nav.cursor = i)}
|
||||
>
|
||||
<td class="px-3">
|
||||
@@ -344,6 +400,30 @@
|
||||
{/snippet}
|
||||
</Box>
|
||||
|
||||
<Triage
|
||||
active={triaging}
|
||||
assignPatch={(id) => ({ category_id: id })}
|
||||
clearPatch={{ category_name: null }}
|
||||
create={createCategory}
|
||||
empty={everything
|
||||
? "Every transaction has a category."
|
||||
: `Every transaction in ${period.label} has a category.`}
|
||||
{everything}
|
||||
ignoreKey="ffui.category-ignored"
|
||||
loading={triage.isPending}
|
||||
noun="category"
|
||||
onactivate={() => (triaging = true)}
|
||||
onchange={() => refreshExcept(triageKey)}
|
||||
onleave={() => (triaging = false)}
|
||||
onswitch={() => setParam("scope", everything ? "" : "all")}
|
||||
{options}
|
||||
periodLabel={period.label}
|
||||
pickKey="c"
|
||||
{places}
|
||||
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
|
||||
title="Transactions without a category"
|
||||
/>
|
||||
|
||||
{#snippet bar(
|
||||
spent: number
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user