diff --git a/src/lib/budgets/map.ts b/src/lib/budgets/map.ts new file mode 100644 index 0000000..2c370e8 --- /dev/null +++ b/src/lib/budgets/map.ts @@ -0,0 +1,28 @@ +const KEY = "ffui.budget-map"; + +export interface Learned { + id: string; + name: string; +} + +function read(): Record { + 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)); +} diff --git a/src/lib/budgets/triage.ts b/src/lib/budgets/triage.ts new file mode 100644 index 0000000..c78dc61 --- /dev/null +++ b/src/lib/budgets/triage.ts @@ -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; + key: string; + split: Split; +} + +export interface Place { + accountId: string; + entries: Entry[]; + key: string; + name: string; + type: string; +} + +export const withoutBudget = (range: Range | null) => + all("/v1/budgets/transactions-without-budget", { + type: "withdrawal", + ...range, + }); + +async function searchFrom( + query: string, + page: number +): Promise[]> { + const result = await api>("/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[], + keep: (split: Split) => boolean +): Place[] { + const places = new Map(); + 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(); + set.add(key); + targets.set(group.id, set); + } + return targets; +} + +let running = 0; +const waiting: (() => void)[] = []; + +async function slot(task: () => Promise): Promise { + if (running < IN_FLIGHT) { + running += 1; + } else { + await new Promise((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 { + 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 { + try { + const value = JSON.parse(localStorage.getItem(key) ?? "[]"); + return new Set(Array.isArray(value) ? value : []); + } catch { + return new Set(); + } + }, + write(set: Set) { + localStorage.setItem(key, JSON.stringify([...set])); + }, + }; +} diff --git a/src/lib/components/budgets/LimitCell.svelte b/src/lib/components/budgets/LimitCell.svelte index 7f8b25e..9da98cb 100644 --- a/src/lib/components/budgets/LimitCell.svelte +++ b/src/lib/components/budgets/LimitCell.svelte @@ -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} > +{:else if suggested && line.amount === null} + {:else} - - {#if explain} - Firefly works this out from the amounts budgeted below. It can't be - edited here or in the API. - {/if} - - - Budgeted - {money(pool.budgeted)} -
-
- -
- {#if available > 0} - {percent(pool.budgeted / available)} - of available - {/if} -
- - Spent - {money(spent)} -
-
- -
- {#if pool.budgeted > 0} - {left < 0 - ? `${money(-left)} overspent` - : `${money(left)} left`} - {/if} -
- - {/if} - {#if allSpent > 0} -

- All expenses in {pool.cur.code}: - {money(allSpent)}. - {#if outside > 0} - {money(outside)} - of it has no budget{noBudgetCount - ? ` (${noBudgetCount} transactions in all currencies)` - : ""} - {/if} -

- {/if} - diff --git a/src/lib/components/budgets/TopLine.svelte b/src/lib/components/budgets/TopLine.svelte new file mode 100644 index 0000000..65e8d11 --- /dev/null +++ b/src/lib/components/budgets/TopLine.svelte @@ -0,0 +1,117 @@ + + + +
+ + {#each columns as label (label)} + {label} + {/each} + {#each tops as top, i (top.cur.code)} + {top.cur.code} + {#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))} +
+ Without a budget + {#if top.outside > 0} + + {:else} + 0 + {/if} +
+ {/each} +
+
+ +{#snippet stat( + label: string, + value: string | null, + tone = "" +)} +
+ {label} + {value ?? "0"} +
+{/snippet} diff --git a/src/lib/components/budgets/Triage.svelte b/src/lib/components/budgets/Triage.svelte new file mode 100644 index 0000000..40fa799 --- /dev/null +++ b/src/lib/components/budgets/Triage.svelte @@ -0,0 +1,673 @@ + + + 0 ? ignoredNote : undefined} + {title} +> +
+

+ {#if loading} + Loading + {:else if openCount === 0} + {empty} + {:else} + {openCount} + {openCount === 1 ? "transaction" : "transactions"} + in {open.length} + {open.length === 1 ? "place" : "places"} + {noun === "budget" ? "have no budget" : "have no category"} + {/if} +

+
+
+ {#each [false, true] as all (all)} + + {/each} +
+ {#if openCount > 0} + {#if active} + + {:else} + + {/if} + {/if} +
+
+ + {#if problem} +

{problem}

+ {:else if rows.length > 0} +
+ + + + + + + + + + + + {#each rows as row, i (row.key)} + {@const done = settled(row)} + {@const cursor = active && nav.cursor === i} + click(i, event)} + > + {#if row.entry} + {@const split = row.entry.split} + + + + + {:else} + {@const rest = done ? row.place.entries : left(row.place)} + + + + + {/if} + + + {#if cursor && !done} + + + + {/if} + {/each} + +
PlaceCountAmountFor example
+ {split.description} + {@render result(row, "sm:hidden")} + + {formatDate(split.date, "short")} + + + + {split.type === "withdrawal" ? "from" : "to"} + {split.type === "withdrawal" + ? split.source_name + : split.destination_name} + + {row.place.name || "(no name)"} + {@render result(row, "sm:hidden")} + {rest.length} + + + {samplesOf(rest).join(", ")} + {@render result(row, "")}
+ {@render strip(row)} +
+
+ {/if} +
+ +{#snippet ignoredNote()} + {hidden} + {hidden === 1 ? "place is" : "places are"} + ignored. + +{/snippet} + +{#snippet result( + row: Row, + extra: string +)} + {@const item = latest(row.key)} + {#if item} +
+ {#if item.state !== "done"} + {item.state === "undoing" + ? "Putting back" + : `Moving to ${item.label}`} + {item.done} + of {item.targets.size} + {:else} + + {item.kind === "ignore" + ? "Ignored" + : `${item.entries.length} moved to ${item.label}`} + + {#if item.error} + {item.error} + {/if} + + {/if} +
+ {:else if !row.entry && hint?.(row.place)} +
+ Usually {hint(row.place)} +
+ {/if} +{/snippet} + +{#snippet strip( + row: Row +)} + {@const count = row.entry ? 1 : left(row.place).length} + {#if picking === row.key} +
+ (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} + /> + ↵ + moves, a new name creates the {noun} + {#if pickError} + {pickError} + {/if} +
+ {:else} +
+ Move {count === 1 ? "it" : `all ${count}`} to + {#each numbered as option, n (option.id)} + + {/each} + + {#if !row.entry} + + + {/if} +
+ {/if} +{/snippet} diff --git a/src/lib/components/budgets/data.ts b/src/lib/components/budgets/data.ts index e3ecfc0..3b8515b 100644 --- a/src/lib/components/budgets/data.ts +++ b/src/lib/components/budgets/data.ts @@ -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): Range => ({ @@ -98,23 +119,20 @@ export async function loadBudgetPage( currencies: Item[] ): Promise { const query = { end: range.end, start: range.start }; - const [budgets, limits, available, total, noBudget, noBudgetPage] = - await Promise.all([ - all("/v1/budgets", query), - all("/v1/budget-limits", query), - all("/v1/available-budgets", query), - api("/v1/insight/expense/total", { query }), - api("/v1/insight/expense/no-budget", { query }), - api>("/v1/budgets/transactions-without-budget", { - query: { ...query, limit: 1, type: "withdrawal" }, - }), - ]); + const [budgets, limits, available, total, groups] = await Promise.all([ + all("/v1/budgets", query), + all("/v1/budget-limits", query), + all("/v1/available-budgets", query), + api("/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(); + 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> { + 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("/v1/budgets", { end: item.end, start: item.start }) + ) + ); + const sums = new Map(); + 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(); + for (const [key, value] of sums) { + if (value > 0) { + out.set(key, niceCeil(value)); + } + } + return out; } const SPACES = /\s/g; diff --git a/src/lib/components/budgets/period-path.svelte.ts b/src/lib/components/budgets/period-path.svelte.ts index 0e811d7..4376364 100644 --- a/src/lib/components/budgets/period-path.svelte.ts +++ b/src/lib/components/budgets/period-path.svelte.ts @@ -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, + }); } }); } diff --git a/src/lib/components/editor/catalog.svelte.ts b/src/lib/components/editor/catalog.svelte.ts index 9cbfcd8..292811a 100644 --- a/src/lib/components/editor/catalog.svelte.ts +++ b/src/lib/components/editor/catalog.svelte.ts @@ -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 { diff --git a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte index 29829bd..2264bc8 100644 --- a/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte +++ b/src/routes/budgets/[[start=date]]/[[end=date]]/+page.svelte @@ -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 = { 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( + 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 | null>(null); + let suggestNote = $state(""); + let saving = $state(false); + let editKey = $state(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>("/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 @@

Could not load budgets: {query.error.message}

{/if} -
- {#each pools as pool (pool.cur.code)} - - {/each} -
+ (triaging = true)} {tops} /> {#snippet tools()} - e - set amount - Tab - next + {#if pending.length > 0} + {pending.length} + suggested from the last 3 months + + + {:else if suggestNote} + {suggestNote} + {:else} + e + set amount + A + suggest from the last 3 months + {/if} {/snippet}
@@ -246,7 +430,15 @@ - + {#if nowCurrent} + + + {/if} + {#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)} 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} /> @@ -418,13 +615,28 @@ : "text-cladd-fg"}" >{money(line, line.amount + line.spent)} - {#if !day.past && day.left !== null && day.left > 0} - {money(line, day.left)}/day - {/if} {/if} + {#if nowCurrent} + + + {/if} - + + ({ 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()} Expenses without a budget ({ + 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>("/v1/categories", { + body: { name }, + method: "POST", + }); + return { id: made.data.id, name: made.data.attributes.name }; + } + let filterInput = $state(); let createInput = $state(); 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), + }, + ]); @@ -285,11 +338,14 @@ {#each rows as row, i (row.item.id)} nav.click(i, event)} + onclick={(event) => { + triaging = false; + nav.click(i, event); + }} oncontextmenu={() => (nav.cursor = i)} >
Budgeted Spent LeftProgressSafe/day + {endsLabel} + Progress
+ {#if safe !== null} + 0 ? "text-cladd-fg" : "text-expense"} + >{money(line, safe)} + {/if} + + {#if ends && line.spent !== 0} + line.amount + ? "text-expense" + : "text-cladd-fg-soft"} + >~{money(line, ends)} + {/if} +
{#if line.amount} @@ -468,7 +680,7 @@
@@ -344,6 +400,30 @@ {/snippet} + ({ 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 )}