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:
hh committed 2026-10-16 15:13:35 +00:00
1 parent 68f64893f7
commit cea51281cd
12 files changed
+1526 -205

No files matched your search

+28
View File
@@ -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));
}
+194
View File
@@ -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]));
},
};
}
+17 -1
View File
@@ -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 -2
View File
@@ -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;
-146
View File
@@ -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>
+117
View File
@@ -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}
+673
View File
@@ -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}
+112 -20
View File
@@ -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([
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 }),
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
query: { ...query, limit: 1, type: "withdrawal" },
}),
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,8 +235,24 @@ export class Catalog {
.catch(() => null);
this.history.set(key, 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> {
const group = await api<One<TransactionGroup>>(
@@ -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"
{#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">Tab</span>
next</span
<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
+84 -4
View File
@@ -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
)}