Compare commits

..
43 Commits
Author SHA1 Message Date
renovate Bot 741e5a49ba chore(deps): update fireflyiii/core:latest docker digest to 4409f9f (main) (#6)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-25 04:11:39 +00:00
renovate Bot 94fe456a9d chore(deps): pin fireflyiii/core docker tag (main) (#2)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-19 04:09:50 +00:00
hh 65d83ddec7 fix(period): [ and ] after month or year to date step by whole months or years
Month to date went back to 13-30 Sept because a partial range was moved
by its length; a range that starts a month, quarter, half or year now
steps by that unit.
2026-10-18 17:26:58 +00:00
hh 54a51752ac fix(subscriptions): card and never-paid wait for the payments instead of guessing while they load 2026-10-18 17:19:43 +00:00
hh 9c15b81409 fix(subscriptions): a payment made around a past due date counts as paid, runway refetches on focus
A bill paid a day before its due date showed as overdue in the runway
because past due dates were never matched against payments; now any
payment within a third of the cycle around the date settles it. The
runway's bills and payments refetch when the tab gets focus, so entries
the bot made meanwhile show up.
2026-10-18 17:02:12 +00:00
hh 4a03ab8e1a feat(period): p and a letter picks a period from any page
p t current period, p m month to date, p 7 and p 3 the last seven and
thirty days, p y year to date, p l last year, p a everything, p c
custom dates, p p opens the period menu, which now shows each key. The
same presets are in the palette. Currencies make primary moves to P and
the subscriptions card/currency toggle to v to free p.
2026-10-17 16:13:29 +00:00
hh 617a4700e2 feat: every page follows the one-currency mode
Totals, info boxes, section headers, group subtotals, shares and the
per-currency charts on the dashboard, transactions, search, accounts,
merchants, statement, budgets, categories, tags, groups, piggy banks,
reports and the category, tag, budget and merchant pages sum into one
converted amount when the mode is on; charts split by currency merge
into one lane. Inputs, reconciliation and the Foreign column stay exact.

Money moved to or from a debt, loan or mortgage no longer counts as
income or spending in the transaction list boxes; it shows as From
debts and To debts. The running balance of an incoming foreign-currency
transfer shows in the account's currency.
2026-10-16 18:49:16 +00:00
hh 94466d9be9 fix: currency selector hidden on phones, space around + between currencies 2026-10-16 18:22:01 +00:00
hh c610aa58fa feat(subscriptions): paying card set for several at once and kept in Firefly, quieter table
Each runway line says whether its card came from the last payment or
was set by hand; x selects bills and a sets the card for all of them,
stored in the Firefly preference ffui_bill_cards so every device sees
it. Price change moves to the subscription page, per year turns grey,
group sums follow the one-currency mode, key chips fit their buttons and
the panels no longer show a focus ring. Linking found payments is y.
2026-10-16 18:22:01 +00:00
hh 54895b9305 feat(accounts): debts in general words, actions named by what they record
Cards are titled by the account name with the position on its own line:
owes you, you owe, settled, and paid back more than borrowed when a debt
crossed zero against its direction, so one account per person covers
both directions. Actions read Record repayment, Lend more, Pay back,
Borrow more and Offset against another debt, which says what it moves.
No more friends in the copy; the dashboard box is Debts.
2026-10-16 18:22:01 +00:00
hh 4936258f32 fix: changing the page, the period or a filter keeps the scroll position 2026-10-16 18:16:06 +00:00
hh f771538f3e feat(editor): s jumps to the subscription field 2026-10-16 18:16:06 +00:00
hh 5a22db43f6 feat(keys): H and L go back and forward from any page, currency selector in the header
Linking a suggested subscription on the transaction page moves from L
to y so L stays forward everywhere.
2026-10-16 18:16:06 +00:00
hh 2231c08eee feat: show every amount in one currency, chosen in the header, $ toggles it
A selector in the header and the palette's Currency group pick the
currency, the primary one by default; the choice persists between pages
and $ switches it on and off. Rates come from Firefly's exchange rates,
then from the owner's own transactions with a foreign amount, walking
through other currencies when there is no direct pair. Amount converts
with a ≈ and keeps the original in its title; Money sums several
currencies into one converted amount.
2026-10-16 18:15:53 +00:00
hh 3c1d8a4867 fix(keys): the first row scrolls to the page top whenever it fits the first screen 2026-10-16 15:54:41 +00:00
hh 722fdc2a20 feat: one page for a category, tag, budget or merchant
The page answers how much and whether that is normal: this month per
currency with count, median and pace against a typical month, thirteen
months as lanes per currency, and what is inside (merchants, cards,
budgets, tags, subtags) on 1 to 4 with Enter narrowing the transactions
below. Budgets keep spent, left and overspent with the limit line;
expense and revenue accounts use the same page with visits and cadence.
The twelve period cards and per-day charts are gone.
2026-10-16 15:51:00 +00:00
hh 349a6a0b32 feat(transactions): the transaction page as a receipt with context
The amount is the headline, notes come first at full width, then the
last twelve visits to the same place as a dot timeline next to the
paying account's days around it with the running balance. Fields edit in
place: j and k move, Enter opens and saves, Esc cancels, s c, s b, s t
and s s jump to classification. A matching subscription shows as a
suggestion, L links it.
2026-10-16 15:51:00 +00:00
hh 58b356ea8b fix(editor): clearing the foreign amount clears it in Firefly
Firefly keeps a foreign amount sent as null on update and only drops it
for "0".
2026-10-16 15:51:00 +00:00
hh f3a597d39c feat(accounts): the account page as a statement, all accounts instead of the audit report
Asset and liability pages open with opening plus income and money moved
in, minus spending and money moved out, equals closing, then thirteen
months of in and out next to the daily balance, and the statement with
its balance column, day subtotals on ; and the last reconciled date.
The audit report becomes one table of all accounts for the period.
2026-10-16 15:36:41 +00:00
hh b10a6bd21a feat(dashboard): where it went and where it came from instead of five column charts 2026-10-16 15:35:42 +00:00
hh fffb770cac feat(accounts): merchants and payers as a league table
Expense and revenue accounts list this month per currency, share,
visits, average, twelve months, typical and the change against it, last
visit and category; quiet ones fold into one line behind i. 1 to 5 sort,
; groups by category with subtotals, x and s c set the category on all
of a merchant's transactions in the period.
2026-10-16 15:35:38 +00:00
hh 26903b0066 feat(reports): a year board per currency instead of the generator
/reports opens the year: per currency spent so far, a typical month and
this month against typical by today, one lane of category columns per
currency with a dotted typical line and income as dots, what grew next
to the month under the cursor, an honest by-currency table and net worth
lanes weighted by size. Arrows move the month, Enter opens its
transactions, [ ] change the year, m shows the old tables, ?view=month
pins a month with the money flow. Old report URLs still work.

Shared pieces for other pages: Lanes, ledgerQuery for twelve months,
typical-month helpers.
2026-10-16 15:35:37 +00:00
hh 8c4e72666b feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days
The right column drops the box per account for one table per currency
with sparklines and the last five transactions on l, a list of what was
entered since the last look (x marks reviewed), bills and piggy bank
dates in the next seven days, and debts with friends net per currency.
The left column opens with spending so far against a typical month, and
$ shows totals in UAH.
2026-10-16 15:14:03 +00:00
hh cea51281cd 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.
2026-10-16 15:13:35 +00:00
hh 68f64893f7 feat(subscriptions): cash runway per card, price changes, stale bills, unlinked payments
The page opens with how much each paying card needs by a date and where
it falls short, a 30-day calendar per card and the annual renewals ahead.
The table shows price changes, the paying card, stale subscriptions to
deactivate in place and payments that look like a subscription, L links
them.
2026-10-16 15:13:35 +00:00
hh 153a8cb191 feat(transactions): the period as a local ledger with Excel-style filters
The list loads the whole period (or twelve months with Y) and filters it
in the browser: a text line over descriptions, notes, accounts and
amounts, dropdowns with counts per type, category, budget, account,
currency, tag and subscription, group-by with subtotals per currency,
pinned filters on 1 to 9. Possible duplicates show with a delete for the
newer one, one account adds the balance column, s s sets a subscription
and s . repeats the last mass action.
2026-10-16 15:13:35 +00:00
hh 01c53d982d feat: tag clean-up converters, saved searches, quick entry subscriptions and foreign amounts
Tag clean-up lists tags named like a category, account or subscription,
tags used once and unused ones; s c and s s move a tag's transactions
to a category or subscription, Enter again deletes the tag. P pins a
search, pins and built-ins show in the palette. Quick entry takes
%subscription and =520 UAH. The transaction page compares the amount
with the usual one at the same place.
2026-10-16 15:10:47 +00:00
hh 6f39343e07 feat(accounts): friends ledger for debts, settle up and offset, asset accounts by currency
Liabilities show one card per friend with who owes whom, last movement,
average days to pay back and twelve months of lending. s settles up,
+ lends more, o offsets two debts through the prefilled editor. Asset
accounts group by currency with totals, inactive ones behind i.
2026-10-16 15:05:56 +00:00
hh 60b099e917 style: Inter with tabular figures, Firefly inks, higher contrast 2026-10-16 14:51:02 +00:00
hh 4e774f5104 feat(transactions): notes folded under rows, i shows them all 2026-10-16 14:51:02 +00:00
hh 0ace2919aa feat(palette): press any button, link or field on the page by name 2026-10-16 14:51:02 +00:00
hh a1e7489ad4 feat(keys): vim keys in any layout, z motions, no Option chords
Keys on a non-Latin layout map to their physical US position, so j, k,
gg and / work on the Russian layout. Lists get z z, z t, z b, and the
first row scrolls the page to the top. The editor drops Alt chords for
Esc then a letter, reorder in groups, piggy banks and subscriptions
moves to J and K.
2026-10-16 14:51:02 +00:00
hh 63a1971900 style(pwa): favicon title 2026-10-16 04:43:51 +00:00
hh beddb6b78f feat(pwa): quick entry deep link and home screen shortcut 2026-10-16 04:42:11 +00:00
hh 07bcc7c8fc feat(shell): quick entry in the create menu for touch screens 2026-10-16 04:40:46 +00:00
hh d348697f26 fix(keys): escape closes palette and cheatsheet, page keys stay quiet behind a modal 2026-10-16 04:38:27 +00:00
hh dea5d8c675 fix(transactions): single-line rows with a foreign column, whole-card period strip under the table 2026-10-16 04:29:46 +00:00
hh e279d3948a fix(transactions,editor,reports): v1 period column, one amount edge, unclipped report numbers, solid save, mobile headers 2026-10-16 04:26:07 +00:00
hh fa322d22e9 style(theme): focus ring in firefly blue 2026-10-16 04:26:07 +00:00
hh f3a603648e fix(budgets,tags,piggy-banks,dashboard): live buttons that answer in place, lucide icons, mobile wrapping 2026-10-16 04:26:07 +00:00
hh aba2509399 style(theme): light mode grey ground with white raised boxes, like v1 2026-10-16 04:26:07 +00:00
renovate Bot 650ff280fb chore(deps): pin oven/bun docker tag to 629e174 (main) (#3)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-16 04:03:22 +00:00
renovate Bot 8b7fac95c6 chore(deps): pin caddy docker tag to d8542f4 (main) (#1)
Co-authored-by: renovate <renovate@hhhhh.dev>
2026-10-16 04:03:19 +00:00
188 changed files with 18918 additions and 3761 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
FROM oven/bun:1-alpine AS deps
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS deps
ENV TERM=xterm-256color
ENV COLORTERM=truecolor
@@ -19,7 +19,7 @@ RUN bun --bun svelte-kit sync
RUN bun run build
FROM oven/bun:1-alpine AS runtime
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS runtime
ENV NODE_ENV=production
+6
View File
@@ -8,6 +8,8 @@
"@biomejs/biome": "^2.5.15",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-sans-3": "^5.3.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-static": "^4.0.0",
@@ -84,6 +86,10 @@
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@fontsource-variable/source-sans-3": ["@fontsource-variable/source-sans-3@5.3.1", "", {}, "sha512-u0p5Z/PM2e1uxYOMiodLM6NVzxHwULcB6Oi7QSr2WU/sdoLBFP/BeOZ3YYHeJ6rHsjPZA1nZweZVaV7sf2MAyQ=="],
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+1 -1
View File
@@ -10,7 +10,7 @@ services:
command: [run, serve.ts]
frontend-dev:
image: oven/bun:alpine
image: oven/bun:alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373
profiles: [frontend-dev]
restart: unless-stopped
working_dir: /app
+2
View File
@@ -21,6 +21,8 @@
"@biomejs/biome": "^2.5.15",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-sans-3": "^5.3.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-static": "^4.0.0",
+271
View File
@@ -0,0 +1,271 @@
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import { addMonths, daysBetween, today } from "#lib/dates.js";
import { formatMoney, toNumber } from "#lib/format.js";
export interface Movement {
amount: number;
counter: { id: string; name: string; type: string };
date: string;
description: string;
group: string;
into: boolean;
opening: boolean;
}
export interface Month {
month: string;
received: number;
sent: number;
}
export type Stance = "owed" | "owe" | "settled";
const OPENING = new Set(["opening balance", "liability credit"]);
const ASSET = "Asset account";
const DUST = 0.005;
export const isDebt = (item: Item<Account>) =>
item.attributes.liability_type === "debt";
export function owed(account: Account): number {
const debt = toNumber(account.debt_amount);
return account.liability_direction === "debit" ? -debt : debt;
}
export function stance(account: Account): Stance {
const position = owed(account);
if (position > DUST) {
return "owed";
}
return position < -DUST ? "owe" : "settled";
}
export const overpaid = (account: Account) =>
toNumber(account.debt_amount) < -DUST;
export function movements(
account: Item<Account>,
groups: Item<TransactionGroup>[]
): Movement[] {
const { id } = account;
const code = account.attributes.currency_code;
const list: Movement[] = [];
for (const group of groups) {
for (const split of group.attributes.transactions) {
const into = split.destination_id === id;
if (!(into || split.source_id === id)) {
continue;
}
const foreign =
split.currency_code !== code && split.foreign_currency_code === code;
const side = into ? "source" : "destination";
list.push({
amount: Math.abs(
toNumber(foreign ? split.foreign_amount : split.amount)
),
counter: {
id: split[`${side}_id`] ?? "",
name: split[`${side}_name`] ?? "",
type: split[`${side}_type`] ?? "",
},
date: split.date.slice(0, 10),
description: split.description,
group: group.id,
into,
opening: OPENING.has(split.type),
});
}
}
return list.sort((a, b) => b.date.localeCompare(a.date));
}
export function averageDays(list: Movement[], credit: boolean): number | null {
const open: { amount: number; date: string }[] = [];
let weighted = 0;
let total = 0;
for (const m of list.toReversed()) {
if (m.into === credit) {
open.push({ amount: m.amount, date: m.date });
continue;
}
let left = m.amount;
while (left > DUST && open.length > 0) {
const [head] = open;
const used = Math.min(left, head.amount);
weighted += used * daysBetween(head.date, m.date);
total += used;
left -= used;
head.amount -= used;
if (head.amount < DUST) {
open.shift();
}
}
}
return total > 0 ? Math.round(weighted / total) : null;
}
export function months(list: Movement[], count = 12): Month[] {
const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count);
const buckets = Array.from({ length: count }, (_, i) => ({
month: addMonths(first, i).slice(0, 7),
received: 0,
sent: 0,
}));
const index = new Map(buckets.map((b, i) => [b.month, i]));
for (const m of list) {
const bucket = buckets[index.get(m.date.slice(0, 7)) ?? -1];
if (!bucket || m.opening) {
continue;
}
if (m.into) {
bucket.sent += m.amount;
} else {
bucket.received += m.amount;
}
}
return buckets;
}
const createUrl = (type: string, query: Record<string, string | undefined>) =>
`/transactions/create/${type}?${new URLSearchParams(
Object.entries(query).filter((entry): entry is [string, string] =>
Boolean(entry[1])
)
)}`;
function counterAsset(
list: Movement[],
assets: Item<Account>[],
code: string | undefined
): string | undefined {
const usable = assets
.filter((a) => a.attributes.active && a.attributes.currency_code === code)
.map((a) => a.id);
return (
list.find((m) => usable.includes(m.counter.id))?.counter.id ?? usable[0]
);
}
const lastSaid = (list: Movement[], into: boolean) =>
list.find((m) => m.into === into && m.counter.type === ASSET)?.description;
const plain = (account: Account, value: number) =>
Math.abs(value).toFixed(account.currency_decimal_places ?? 2);
const money = (account: Account, value: number) =>
formatMoney({
amount: Math.abs(value),
code: account.currency_code,
decimals: account.currency_decimal_places,
symbol: account.currency_symbol,
});
export interface Action {
hint: string;
href: string;
label: string;
line?: string;
}
export interface Actions {
more: Action;
offset: Action | null;
repay: Action | null;
}
interface Move {
amount?: string;
asset?: string;
fallback: string;
label: string;
toThem: boolean;
}
function moveAction(item: Item<Account>, list: Movement[], move: Move) {
const a = item.attributes;
const said = overpaid(a) ? undefined : lastSaid(list, move.toThem);
return {
hint: `${move.label}: ${a.name}`,
href: createUrl(move.toThem ? "withdrawal" : "deposit", {
amount: move.amount,
description: said ?? move.fallback,
destination: move.toThem ? item.id : move.asset,
source: move.toThem ? move.asset : item.id,
}),
label: move.label,
};
}
function repayAction(item: Item<Account>, list: Movement[], asset?: string) {
const a = item.attributes;
const side = stance(a);
if (side === "settled") {
return null;
}
const toThem = side === "owe";
return moveAction(item, list, {
amount: plain(a, owed(a)),
asset,
fallback: toThem ? "Paid back" : "Repayment",
label: toThem ? "Pay back" : "Record repayment",
toThem,
});
}
function moreAction(item: Item<Account>, list: Movement[], asset?: string) {
const a = item.attributes;
const side = stance(a);
const toThem =
side === "settled" ? a.liability_direction !== "debit" : side === "owed";
return moveAction(item, list, {
asset,
fallback: toThem ? "Lent" : "Borrowed",
label: toThem ? "Lend more" : "Borrow more",
toThem,
});
}
function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
const a = item.attributes;
const position = owed(a);
const [other] = debts
.filter(
(d) =>
d.attributes.currency_code === a.currency_code &&
owed(d.attributes) * position < 0
)
.sort(
(x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes))
);
if (!other) {
return null;
}
const amount = Math.min(Math.abs(position), Math.abs(owed(other.attributes)));
const [from, to] = position > 0 ? [item, other] : [other, item];
const label = "Offset against another debt";
return {
hint: `${label}: ${a.name}`,
href: createUrl("transfer", {
amount: plain(a, amount),
description: `Offset ${from.attributes.name} against ${to.attributes.name}`,
destination: to.id,
source: from.id,
}),
label,
line: `Moves ${money(a, amount)} from ${from.attributes.name} to ${to.attributes.name} so both debts shrink.`,
};
}
export function actionsFor(
item: Item<Account>,
list: Movement[],
assets: Item<Account>[],
debts: Item<Account>[]
): Actions {
const asset = counterAsset(list, assets, item.attributes.currency_code);
return {
more: moreAction(item, list, asset),
offset: offsetAction(item, debts),
repay: repayAction(item, list, asset),
};
}
+116
View File
@@ -0,0 +1,116 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import type { TransactionGroup } from "#lib/api/types.js";
import { today } from "#lib/dates.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import type { Flow } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { pages } from "#lib/reports/queries.js";
import { impliedRates } from "#lib/reports/rates.js";
import { typicalMonths } from "#lib/reports/typical.js";
import { session } from "#lib/session.svelte.js";
import { board, type Kind, monthDay } from "./merchants.js";
export function useMerchants(kind: () => Kind) {
const now = today();
const current = ledgerWindow(now);
const anchor = $derived(period.end >= current.start ? now : period.end);
const window = $derived(ledgerWindow(anchor));
const inside = $derived(
period.start >= window.start && period.end <= window.end
);
const accounts = createQuery(() => lists.accounts("all"));
const ledger = createQuery(() => ({
...ledgerQuery(anchor),
placeholderData: keepPreviousData,
}));
const extra = createQuery(() => ({
enabled: !inside,
placeholderData: keepPreviousData,
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end: period.end,
start: period.start,
type: "default",
}),
queryKey: ["merchants-period", period.start, period.end],
}));
const own = $derived(
ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : []
);
const ownPeriod = $derived.by(() => {
if (inside) {
return own;
}
return extra.data && accounts.data
? ownFlows(extra.data, accounts.data)
: [];
});
const rates = $derived.by(() => {
if (!display.active) {
return null;
}
const map = new Map<string, number>();
for (const code of new Set([...own, ...ownPeriod].map((f) => f.currency))) {
const rate = display.rate(code);
if (rate === null) {
return null;
}
map.set(code, rate);
}
return map;
});
const merge = $derived(rates ? display.code : null);
const inTarget = (list: Flow[]) =>
rates
? list.map((flow) => ({
...flow,
amount: flow.amount * (rates.get(flow.currency) ?? 0),
}))
: list;
const flows = $derived(inTarget(own));
const periodFlows = $derived(inside ? flows : inTarget(ownPeriod));
const implied = $derived(impliedRates(ledger.data ?? []));
const day = $derived(monthDay(period.range, now));
const result = $derived.by(() => {
if (!(accounts.data && ledger.data && (inside || extra.data))) {
return null;
}
const month = period.end.slice(0, 7);
const since = flows.reduce(
(first, flow) => (flow.date < first ? flow.date : first),
now
);
const base = merge ?? session.primary?.code ?? "";
return board(accounts.data, flows, periodFlows, {
day,
kind: kind(),
merge,
months: window.months.filter((m) => m <= month),
past: typicalMonths(window.months, period.end, since),
range: period.range,
rate: (code) => implied(code, base) ?? 0,
});
});
return {
get board() {
return result;
},
get day() {
return day;
},
get error() {
return accounts.error ?? ledger.error ?? extra.error ?? null;
},
get loading() {
return result === null;
},
get stale() {
return ledger.isFetching || extra.isPlaceholderData;
},
};
}
+310
View File
@@ -0,0 +1,310 @@
import type { Account, Item } from "#lib/api/types.js";
import type { Range } from "#lib/dates.js";
import type { Flow } from "#lib/reports/flows.js";
import { typical, typicalToDay, versus } from "#lib/reports/typical.js";
import type { Targets } from "#lib/transactions/mass.js";
export type Kind = "expense" | "revenue";
export type SortKey = "month" | "year" | "visits" | "last" | "name";
export const SORT_KEYS: SortKey[] = ["month", "year", "visits", "last", "name"];
export const SPARK_MONTHS = 12;
const TOP = 5;
export interface Line {
amount: number;
approx: boolean;
base: number;
code: string;
months: number[];
ratio: number | null;
seen: number;
typical: number;
visits: number;
}
export interface Merchant {
category: string | null;
fresh: boolean;
id: string;
last: string;
lines: Line[];
name: string;
others: number;
quiet: boolean;
share: number;
targets: Targets;
value: number;
visits: number;
year: number;
}
export interface Total {
amount: number;
approx: boolean;
base: number;
code: string;
ratio: number | null;
}
export interface Section {
key: string;
label: string;
merchants: Merchant[];
share: number;
sums: Total[];
visits: number;
}
export interface Board {
fresh: number;
merchants: Merchant[];
past: number;
top: number;
totals: Total[];
}
export interface Context {
day: number | null;
kind: Kind;
merge: string | null;
months: string[];
past: string[];
range: Range;
rate: (code: string) => number;
}
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
const foreign = (flows: Flow[], code: string) =>
flows.some((flow) => flow.currency !== code);
function monthly(flows: Flow[]): Map<string, number> {
const map = new Map<string, number>();
for (const flow of flows) {
const month = flow.date.slice(0, 7);
map.set(month, (map.get(month) ?? 0) + flow.amount);
}
return map;
}
function lineOf(code: string, year: Flow[], now: Flow[], ctx: Context): Line {
const byMonth = monthly(year);
const amount = sum(now.map((flow) => flow.amount));
const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day);
return {
amount,
approx: foreign(year, code) || foreign(now, code),
base,
code,
months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0),
ratio: ctx.day === null ? null : versus(amount, base),
seen: ctx.past.filter((m) => byMonth.has(m)).length,
typical: typical(byMonth, ctx.past),
visits: now.length,
};
}
function dominant(flows: Flow[]): [string | null, number] {
const counts = new Map<string, number>();
for (const flow of flows) {
const name = flow.category?.name ?? "";
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const [top] = [...counts].sort((a, b) => b[1] - a[1]);
return [top?.[0] || null, Math.max(0, counts.size - 1)];
}
function targetsOf(flows: Flow[]): Targets {
const targets: Targets = new Map();
for (const flow of flows) {
const set = targets.get(flow.group) ?? new Set<string>();
set.add(flow.journal);
targets.set(flow.group, set);
}
return targets;
}
function bucket<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const list = map.get(key(item));
if (list) {
list.push(item);
} else {
map.set(key(item), [item]);
}
}
return map;
}
const codeOf = (ctx: Context) => (flow: Flow) => ctx.merge ?? flow.currency;
const latest = (flows: Flow[]) =>
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
function merchantOf(
id: string,
name: string,
seen: string,
year: Flow[],
now: Flow[],
ctx: Context
): Omit<Merchant, "share"> {
const yearBy = bucket(year, codeOf(ctx));
const nowBy = bucket(now, codeOf(ctx));
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
const quiet = now.length === 0;
const lines = [...codes]
.map((code) =>
lineOf(code, yearBy.get(code) ?? [], nowBy.get(code) ?? [], ctx)
)
.filter((line) => (quiet ? sum(line.months) > 0 : line.amount > 0))
.sort(
(a, b) =>
b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code) ||
sum(b.months) * ctx.rate(b.code) - sum(a.months) * ctx.rate(a.code)
);
const [category, others] = dominant(quiet ? year : now);
const before = year.filter((flow) => flow.date < ctx.range.start);
return {
category,
fresh: !quiet && before.length === 0,
id,
last: latest(now) || seen || latest(year),
lines,
name,
others,
quiet,
targets: targetsOf(now),
value: sum(lines.map((line) => line.amount * ctx.rate(line.code))),
visits: now.length,
year: sum(lines.map((line) => sum(line.months) * ctx.rate(line.code))),
};
}
export function board(
accounts: Item<Account>[],
flows: Flow[],
periodFlows: Flow[],
ctx: Context
): Board {
const dir = ctx.kind === "expense" ? -1 : 1;
const inRange = (flow: Flow) =>
flow.date >= ctx.range.start && flow.date <= ctx.range.end;
const mine = flows.filter((flow) => flow.dir === dir);
const now = periodFlows.filter((flow) => flow.dir === dir && inRange(flow));
const yearBy = bucket(mine, (flow) => flow.other.id);
const nowBy = bucket(now, (flow) => flow.other.id);
const names = new Map(
[...mine, ...now].map((flow) => [flow.other.id, flow.other.name])
);
const seen = new Map<string, string>();
for (const { id, attributes: a } of accounts) {
if (a.type === ctx.kind && (a.active !== false || names.has(id))) {
names.set(id, a.name);
seen.set(id, a.last_activity?.slice(0, 10) ?? "");
}
}
const drafts = [...names].map(([id, name]) =>
merchantOf(
id,
name,
seen.get(id) ?? "",
yearBy.get(id) ?? [],
nowBy.get(id) ?? [],
ctx
)
);
const total = sum(drafts.map((m) => m.value)) || 1;
const merchants = drafts
.map((m) => ({ ...m, share: m.value / total }))
.sort(sortBy("month"));
return {
fresh: merchants.filter((m) => m.fresh).length,
merchants,
past: ctx.past.length,
top: sum(merchants.slice(0, TOP).map((m) => m.share)),
totals: totalsOf(mine, now, ctx),
};
}
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
const nowBy = bucket(now, codeOf(ctx));
const yearBy = bucket(year, codeOf(ctx));
return [...nowBy]
.map(([code, list]) => {
const amount = sum(list.map((flow) => flow.amount));
const base =
ctx.day === null
? 0
: typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day);
return {
amount,
approx: foreign(list, code),
base,
code,
ratio: ctx.day === null ? null : versus(amount, base),
};
})
.sort((a, b) => b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code));
}
const ORDER: Record<SortKey, (a: Merchant, b: Merchant) => number> = {
last: (a, b) => b.last.localeCompare(a.last),
month: (a, b) => b.value - a.value,
name: (a, b) => a.name.localeCompare(b.name),
visits: (a, b) => b.visits - a.visits,
year: (a, b) => b.year - a.year,
};
export const sortBy =
(key: SortKey) =>
(a: Merchant, b: Merchant): number =>
ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b);
export function sumLines(merchants: Merchant[]): Total[] {
const map = new Map<string, Total>();
for (const line of merchants.flatMap((m) => m.lines)) {
const total = map.get(line.code) ?? {
amount: 0,
approx: false,
base: 0,
code: line.code,
ratio: null,
};
total.amount += line.amount;
total.approx ||= line.approx;
map.set(line.code, total);
}
return [...map.values()].filter((total) => total.amount > 0);
}
export function byCategory(merchants: Merchant[], none: string): Section[] {
return [...bucket(merchants, (m) => m.category ?? "")]
.map(([key, list]) => ({
key: key || "none",
label: key || none,
merchants: list,
share: sum(list.map((m) => m.share)),
sums: sumLines(list),
visits: sum(list.map((m) => m.visits)),
}))
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
}
const WHOLE_MONTH = 31;
export function monthDay(range: Range, now: string): number | null {
const month = range.start.slice(0, 7);
const last = new Date(
Number(month.slice(0, 4)),
Number(month.slice(5, 7)),
0
).getDate();
const whole =
range.start === `${month}-01` && range.end === `${month}-${last}`;
if (!whole || month > now.slice(0, 7)) {
return null;
}
return month === now.slice(0, 7) ? Number(now.slice(8, 10)) : WHOLE_MONTH;
}
+240
View File
@@ -0,0 +1,240 @@
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import {
addDays,
addMonths,
contains,
eachDay,
type Range,
} from "#lib/dates.js";
import {
compactMoney,
formatMoney,
type Money,
toNumber,
} from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
export type Flow =
| "income"
| "movedIn"
| "spent"
| "movedOut"
| "opening"
| "adjusted";
export type Side = "asset" | "credit" | "debit";
export type Tone = "income" | "expense" | "transfer" | "neutral";
export interface Move {
amount: number;
date: string;
flow: Flow;
reconciled: boolean;
}
export interface Anchor {
balance: number;
date: string;
}
export interface Term {
flow: Flow;
label: string;
tone: Tone;
up: boolean;
value: number;
}
type Part = Omit<Term, "up">;
export type Sums = Record<Flow, number>;
export const MONTHS = 13;
export const FOUR: Flow[] = ["income", "movedIn", "spent", "movedOut"];
const FLOWS: Flow[] = [...FOUR, "opening", "adjusted"];
const REAL = new Set(["withdrawal", "deposit"]);
const OPENING = new Set(["opening balance", "liability credit"]);
const CENT = 0.005;
const PRECISION = 1e6;
const clean = (value: number) => Math.round(value * PRECISION) / PRECISION;
const WORDS: Record<Side, { into: string; out: string }> = {
asset: { into: "Income", out: "Spent" },
credit: { into: "Lent", out: "Got back" },
debit: { into: "Paid back", out: "Borrowed" },
};
export const sideOf = (account: Account, liability: boolean): Side => {
if (!liability) {
return "asset";
}
return account.liability_direction === "debit" ? "debit" : "credit";
};
export const signOf = (side: Side) => (side === "debit" ? -1 : 1);
function flowOf(type: string, into: boolean): Flow {
if (type === "transfer") {
return into ? "movedIn" : "movedOut";
}
if (REAL.has(type)) {
return into ? "income" : "spent";
}
return OPENING.has(type) ? "opening" : "adjusted";
}
export function moves(
id: string,
code: string | null | undefined,
groups: Item<TransactionGroup>[]
): Move[] {
const list: Move[] = [];
for (const group of groups) {
for (const split of group.attributes.transactions) {
const into = split.destination_id === id;
if (!(into || split.source_id === id)) {
continue;
}
const foreign =
split.currency_code !== code && split.foreign_currency_code === code;
const amount = Math.abs(
toNumber(foreign ? split.foreign_amount : split.amount)
);
list.push({
amount: into ? amount : -amount,
date: split.date.slice(0, 10),
flow: flowOf(split.type, into),
reconciled: Boolean(split.reconciled),
});
}
}
return list.sort((a, b) => a.date.localeCompare(b.date));
}
export function sums(list: Move[], range: Range) {
const total = Object.fromEntries(FLOWS.map((f) => [f, 0])) as Sums;
let rows = 0;
for (const m of list) {
if (contains(range, m.date)) {
total[m.flow] += m.amount;
rows += 1;
}
}
for (const flow of FLOWS) {
total[flow] = clean(total[flow]);
}
return { rows, total };
}
export function balanceAt(list: Move[], anchor: Anchor, day: string): number {
let value = anchor.balance;
for (const m of list) {
if (m.date > anchor.date && m.date <= day) {
value += m.amount;
} else if (m.date <= anchor.date && m.date > day) {
value -= m.amount;
}
}
return clean(value);
}
export function daily(list: Move[], anchor: Anchor, range: Range) {
const byDay = new Map<string, number>();
for (const m of list) {
byDay.set(m.date, (byDay.get(m.date) ?? 0) + m.amount);
}
let value = balanceAt(list, anchor, addDays(range.start, -1));
return eachDay(range).map((day) => {
value = clean(value + (byDay.get(day) ?? 0));
return { x: day, y: value };
});
}
export function monthsOf(range: Range, list: Move[]) {
return Array.from({ length: MONTHS }, (_, i) => {
const start = addMonths(range.start, i);
return {
month: start,
total: sums(list, { end: addDays(addMonths(start, 1), -1), start }).total,
};
});
}
export function chartRange(period: Range, now: string): Range {
const current = `${now.slice(0, 7)}-01`;
const asked = `${period.end.slice(0, 7)}-01`;
const recent = asked <= current && asked >= addMonths(current, 1 - MONTHS);
const last = recent ? current : asked;
return {
end: addDays(addMonths(last, 1), -1),
start: addMonths(last, 1 - MONTHS),
};
}
export function terms(side: Side, total: Sums): Term[] {
const s = signOf(side);
const words = WORDS[side];
const asset = side === "asset";
const into: Part = {
flow: "income",
label: words.into,
tone: asset ? "income" : "expense",
value: s * total.income,
};
const movedIn: Part = {
flow: "movedIn",
label: "Moved in",
tone: "transfer",
value: s * total.movedIn,
};
const out: Part = {
flow: "spent",
label: words.out,
tone: asset ? "expense" : "income",
value: s * total.spent,
};
const movedOut: Part = {
flow: "movedOut",
label: "Moved out",
tone: "transfer",
value: s * total.movedOut,
};
const four =
s > 0 ? [into, movedIn, out, movedOut] : [out, movedOut, into, movedIn];
const extra: Part[] = [
{
flow: "opening",
label: "Opening balance",
tone: "neutral",
value: s * total.opening,
},
{
flow: "adjusted",
label: "Reconciled",
tone: "neutral",
value: s * total.adjusted,
},
];
return [
...four.map((t, i) => ({ ...t, up: i < 2 })),
...extra
.filter((t) => Math.abs(t.value) > CENT)
.map((t) => ({ ...t, up: t.value > 0 })),
];
}
export function reconciledThrough(list: Move[]): string | null {
return list.findLast((m) => m.reconciled)?.date ?? null;
}
export function viewOf(cur: Omit<Money, "amount">) {
const rate = cur.code === display.code ? null : display.rate(cur.code);
const shown = rate === null ? cur : display.target;
const mark = rate === null ? "" : "≈";
return {
axis: (value: number) => compactMoney(value, shown.symbol ?? ""),
format: (value: number, signed = false) =>
`${mark}${formatMoney({ ...shown, amount: value }, signed)}`,
rate: rate ?? 1,
};
}
+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]));
},
};
}
+27 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { formatMoney, type Money, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
@@ -10,10 +11,31 @@
decimals,
tone = "auto",
signed = false,
approx = false,
exact = false,
class: className = "",
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
}: Money & {
tone?: Tone;
signed?: boolean;
approx?: boolean;
exact?: boolean;
class?: string;
} = $props();
const value = $derived(toNumber(amount));
const converted = $derived(
exact || !code || code === display.code
? null
: display.convert(value, code)
);
const original = $derived(
formatMoney({ amount, code, decimals, symbol }, signed)
);
const shown = $derived(
converted === null
? original
: formatMoney({ ...display.target, amount: converted }, signed)
);
const resolved = $derived.by(() => {
if (tone !== "auto") {
return tone;
@@ -33,6 +55,8 @@
);
</script>
<span class="num {color} {className}"
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
<span
class="num font-medium {color} {className}"
title={converted === null ? undefined : original}
>{approx || converted !== null ? "≈" : ""}{shown}</span
>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import type { Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import Amount, { type Tone } from "./Amount.svelte";
let {
parts,
tone = "auto",
signed = false,
inline = false,
class: className = "",
}: {
parts: Money[];
tone?: Tone;
signed?: boolean;
inline?: boolean;
class?: string;
} = $props();
const total = $derived(display.total(parts));
</script>
{#if total}
<Amount
amount={total.amount}
approx={total.converted}
class={className}
code={total.code}
decimals={total.decimals}
{signed}
symbol={total.symbol}
{tone}
/>
{:else if inline}
{#each parts as part, index (part.code ?? index)}
{#if index > 0}
<span class="mx-1 text-cladd-fg-softer">+</span>
{/if}
<Amount {...part} class={className} {signed} {tone} />
{/each}
{:else}
{#each parts as part, index (part.code ?? index)}
<div><Amount {...part} class={className} {signed} {tone} /></div>
{/each}
{/if}
+17 -6
View File
@@ -26,6 +26,7 @@
tagsOf,
} from "#lib/components/editor/quick.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
import { evalAmount } from "#lib/components/inputs/parse.js";
import { LIST } from "#lib/components/inputs/styles.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
@@ -35,7 +36,7 @@
const SUGGESTIONS = 6;
const HINT =
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
"12.50 lunch @payee ^account !category $budget %subscription #tag ~yesterday · + in front for a deposit · >account for a transfer · 12.50 USD =520 UAH for another currency";
const catalog = new Catalog();
let input = $state<HTMLInputElement | null>(null);
@@ -61,6 +62,7 @@
"!": () => catalog.categoryOptions(query),
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
"#": () => catalog.tagOptions(query, tagsOf(quick)),
"%": () => catalog.billOptions(query),
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
"~": () => [],
@@ -107,12 +109,18 @@
function money(form: Form) {
const [split] = form.splits;
return formatMoney({
amount: quick.amount ?? 0,
code: split.currency?.code,
decimals: split.currency?.decimals,
symbol: split.currency?.symbol,
const main = formatMoney({
...split.currency,
amount: evalAmount(split.amount) ?? 0,
});
if (!split.foreignCurrency) {
return main;
}
const foreign = formatMoney({
...split.foreignCurrency,
amount: evalAmount(split.foreignAmount) ?? 0,
});
return `${main} (${foreign})`;
}
function syncCaret() {
@@ -252,6 +260,9 @@
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
});
}
if (split.bill.id) {
parts.push({ text: `subscription ${split.bill.name}` });
}
if (split.budget.id) {
parts.push({
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
+62 -23
View File
@@ -2,6 +2,7 @@
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui";
import { viewOf } from "#lib/accounts/statement.js";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
@@ -19,19 +20,22 @@
setPrimary,
span,
} from "#lib/components/dashboard/data.js";
import Merchant from "#lib/components/dimension/Merchant.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { contains, daysBetween, today } from "#lib/dates.js";
import { formatMoney, formatRange } from "#lib/format.js";
import { formatRange } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Breakdown from "./Breakdown.svelte";
import DebtPanel from "./DebtPanel.svelte";
import {
canReconcile,
createUrl,
@@ -47,6 +51,7 @@
typeOf,
} from "./links.js";
import PeriodList from "./PeriodList.svelte";
import Statement from "./Statement.svelte";
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
const BUTTON =
@@ -69,9 +74,14 @@
const a = $derived(account.data?.data.attributes);
const type = $derived(a ? typeOf(a) : "asset");
const item = $derived(account.data?.data);
const debt = $derived(a?.liability_type === "debt");
const statement = $derived(
!everything && (type === "asset" || type === "liabilities")
);
const merchant = $derived(type === "expense" || type === "revenue");
const first = createQuery(() => ({
enabled: everything,
enabled: everything && !merchant,
queryFn: async () => {
const head = await api<Page<TransactionGroup>>(
`/v1/accounts/${id}/transactions`,
@@ -110,7 +120,7 @@
return days <= 3000 ? "1M" : "1Y";
});
const chart = createQuery(() => ({
enabled: !everything || first.isSuccess,
enabled: !(statement || merchant) && (!everything || first.isSuccess),
placeholderData: keepPreviousData,
queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", {
@@ -123,25 +133,34 @@
}),
queryKey: ["account-chart", id, range.start, range.end, step],
}));
const converted = $derived(hasPrimary(chart.data ?? []));
const series = $derived(
(chart.data ?? []).map((set) => ({
area: true,
format: money(converted ? setPrimary(set) : currency(set.currency_code)),
key: set.label,
label: set.label,
points: seriesPoints(set, converted),
}))
);
const converted = $derived(!display.active && hasPrimary(chart.data ?? []));
const cur = $derived(
currency(a?.currency_code ?? session.primary?.code ?? "")
);
const view = $derived(display.active ? viewOf(cur) : null);
const series = $derived(
(chart.data ?? []).map((set) => {
const own = viewOf(currency(set.currency_code));
return {
area: true,
format: view
? own.format
: money(converted ? setPrimary(set) : currency(set.currency_code)),
key: set.label,
label: set.label,
points: seriesPoints(set, converted).map((p) => ({
...p,
y: p.y * own.rate,
})),
};
})
);
const pageNo = $derived(
Number(page.url.searchParams.get("page") ?? "1") || 1
);
const dated = createQuery(() => ({
enabled: !everything,
enabled: !(everything || statement || merchant),
placeholderData: keepPreviousData,
queryFn: () =>
all<TransactionGroup>(
@@ -155,7 +174,7 @@
queryKey: ["account-tx-all", id, period.start, period.end],
}));
const paged = createQuery(() => ({
enabled: everything,
enabled: everything && !merchant,
placeholderData: keepPreviousData,
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
@@ -199,7 +218,7 @@
return TYPE_TITLE[type].replace(" accounts", " account");
});
const balance = $derived(
a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
a ? display.text([{ amount: a.current_balance, ...cur }]) : ""
);
function pageHref(n: number) {
@@ -300,26 +319,44 @@
<p class="pb-3 text-expense">{failure}</p>
{/if}
<div class="flex flex-col gap-3">
{#if statement}
{#if item}
<Statement {item} />
{/if}
{:else if merchant}
<Merchant {everything} {id} />
{:else}
<div class="flex flex-col gap-3">
<div
class="grid gap-3 {debt
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
: ""}"
>
{#if debt && item}
<DebtPanel {item} />
{/if}
<Box
title={a
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
: "Chart"}
>
<div
class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
class="transition-opacity {chart.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if chart.isPending || (everything && first.isPending)}
<div class="h-[300px]"></div>
{:else if chart.isError}
<p class="text-expense">{chart.error.message}</p>
{:else if series.length === 0}
<p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
<p class="py-10 text-center text-cladd-fg-soft">
No balance to draw.
</p>
{:else}
<LineChart
axisFormat={axis(
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
)}
axisFormat={view?.axis ??
axis(converted ? setPrimary(chart.data?.[0] as ChartSet) : cur)}
height={300}
label="Balance of {a?.name ?? "the account"}"
{marker}
@@ -329,6 +366,7 @@
{/if}
</div>
</Box>
</div>
{#if !everything}
<div class="grid gap-3 md:grid-cols-3">
@@ -408,4 +446,5 @@
</div>
{/if}
</div>
</div>
</div>
{/if}
+169 -68
View File
@@ -13,9 +13,11 @@
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import {
balanceRange,
byCurrency,
type ChartSet,
seriesPoints,
} from "#lib/components/dashboard/data.js";
import Money from "#lib/components/Money.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
ContextMenu,
@@ -26,8 +28,9 @@
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { daysBetween } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { formatDate, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -123,15 +126,33 @@
let armed = $state(false);
let failure = $state("");
const ROLE_RANK: Record<string, number> = {
cashWalletAsset: 0,
ccAsset: 1,
defaultAsset: 2,
savingAsset: 4,
sharedAsset: 3,
};
const perCurrency = $derived(type === "asset" && active);
const showInactive = $derived(
perCurrency && page.url.searchParams.has("inactive")
);
const accounts = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
all<Account>("/v1/accounts", {
...balanceRange(period.start, period.end),
active: String(active),
...(showInactive ? {} : { active: String(active) }),
type,
}),
queryKey: ["accounts-index", type, active, period.start, period.end],
queryKey: [
"accounts-index",
type,
showInactive ? "all" : active,
period.start,
period.end,
],
}));
const others = createQuery(() => ({
queryFn: () =>
@@ -182,6 +203,8 @@
function sortValue(item: Item<Account>, key: Key): string | number {
const a = item.attributes;
const shown = (value: string | null | undefined) =>
display.convert(toNumber(value), a.currency_code) ?? toNumber(value);
switch (key) {
case "name":
return a.name.toLowerCase();
@@ -196,15 +219,15 @@
case "number":
return formatIban(a);
case "balance":
return toNumber(a.current_balance);
return shown(a.current_balance);
case "due":
return toNumber(a.debt_amount);
return shown(a.debt_amount);
case "active":
return a.active ? 1 : 0;
case "activity":
return a.last_activity ?? "";
default:
return toNumber(a.balance_difference);
return shown(a.balance_difference);
}
}
@@ -228,8 +251,12 @@
const sorted = $derived.by(() => {
const list = [...matching];
if (!sortKey) {
const rank = (item: Item<Account>) =>
perCurrency ? (ROLE_RANK[item.attributes.account_role ?? ""] ?? 5) : 0;
return list.sort(
(x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
(x, y) =>
rank(x) - rank(y) ||
(x.attributes.order ?? 0) - (y.attributes.order ?? 0)
);
}
const dir = descending ? -1 : 1;
@@ -242,10 +269,48 @@
return (a > b ? 1 : -1) * dir;
});
});
interface Total {
balance: number;
code: string;
decimals: number;
difference: number;
due: number;
symbol: string;
}
const totals = $derived.by(() => {
const map = new Map<string, Total>();
for (const { attributes: a } of matching) {
const code = a.currency_code ?? "";
const t = map.get(code) ?? {
balance: 0,
code,
decimals: a.currency_decimal_places ?? 2,
difference: 0,
due: 0,
symbol: a.currency_symbol ?? code,
};
t.balance += toNumber(a.current_balance);
t.difference += toNumber(a.balance_difference);
t.due += toNumber(a.debt_amount);
map.set(code, t);
}
return [...map.values()];
});
const parts = (list: Total[], key: "balance" | "difference" | "due") =>
list.map((t) => ({ ...t, amount: t[key] }));
const merged = $derived(display.total(parts(totals, "balance")) !== null);
const sections = $derived(perCurrency && !merged);
const grouped = $derived(
sorted.some((item) => item.attributes.object_group_title)
sections || sorted.some((item) => item.attributes.object_group_title)
);
const groups = $derived.by(() => {
if (sections) {
return byCurrency(
sorted,
(item) => item.attributes.currency_code ?? ""
).map(([code, items]) => ({ items, title: code }));
}
if (!grouped) {
return [{ items: sorted, title: "" }];
}
@@ -269,34 +334,6 @@
const rows = $derived(groups.flatMap((g) => g.items));
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
interface Total {
balance: number;
code: string;
decimals: number;
difference: number;
due: number;
symbol: string;
}
const totals = $derived.by(() => {
const map = new Map<string, Total>();
for (const { attributes: a } of rows) {
const code = a.currency_code ?? "";
const t = map.get(code) ?? {
balance: 0,
code,
decimals: a.currency_decimal_places ?? 2,
difference: 0,
due: 0,
symbol: a.currency_symbol ?? code,
};
t.balance += toNumber(a.current_balance);
t.difference += toNumber(a.balance_difference);
t.due += toNumber(a.debt_amount);
map.set(code, t);
}
return [...map.values()];
});
const title = $derived(
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
);
@@ -327,6 +364,18 @@
}
},
},
...(perCurrency
? [
{
group: "Accounts",
keys: "i",
label: showInactive
? "Hide inactive accounts"
: "Show inactive accounts",
run: () => goto(inactiveHref(), { replace: true }),
},
]
: []),
],
id: "accounts",
items: () => rows,
@@ -335,6 +384,18 @@
});
nav.use();
function inactiveHref() {
const url = new URL(page.url.href);
if (showInactive) {
url.searchParams.delete("inactive");
} else {
url.searchParams.set("inactive", "1");
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
return url.pathname + url.search;
}
function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) {
filter = "";
@@ -417,6 +478,47 @@
}
</script>
{#snippet sums(
list: Total[],
label: string,
head = false
)}
{#each columns as col, c (col.key)}
<td
class="px-3 {col.num ? "text-right" : ""} {col.wide
? "hidden md:table-cell"
: ""}"
>
{#if c === 0}
<span
class="font-semibold text-[12px] {head
? "text-cladd-fg"
: "text-cladd-fg-soft"}"
>{label}</span
>
{#if head}
<Money
class="ml-2 text-[12px] md:hidden"
inline
parts={parts(list, "difference")}
signed
/>
{/if}
{:else if col.key === "balance"}
<span class="num font-semibold"
>{display.text(parts(list, "balance"))}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{display.text(parts(list, "due"))}</span
>
{:else if col.key === "difference"}
<Money class="font-semibold" parts={parts(list, "difference")} />
{/if}
</td>
{/each}
{/snippet}
<PageHeader
crumbs={[{ label: title }]}
subtitle={active ? undefined : "Archive"}
@@ -502,7 +604,15 @@
</thead>
<tbody>
{#each groups as group (group.title)}
{#if grouped}
{#if sections}
<tr class="h-8 border-cladd-outline border-t-2">
{@render sums(
totals.filter((t) => t.code === group.title),
group.title,
true
)}
</tr>
{:else if grouped}
<tr class="h-8 border-cladd-outline border-t">
<th
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
@@ -517,7 +627,9 @@
{@const a = item.attributes}
{@const i = index.get(item.id) ?? -1}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
class="h-7 border-cladd-outline/60 border-t {rowClass(
i
)} {a.active ? "" : "opacity-60"}"
data-id={item.id}
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
@@ -603,40 +715,19 @@
</tr>
{/if}
</tbody>
{#if totals.length > 0}
{#if totals.length > 0 && !sections}
<tfoot class="border-cladd-outline border-t-2">
{#if merged}
<tr class="h-7">
{@render sums(totals, "Total")}
</tr>
{:else}
{#each totals as t (t.code)}
<tr class="h-7">
{#each columns as col, c (col.key)}
<td
class="px-3 {col.num ? "text-right" : ""} {col.wide
? "hidden md:table-cell"
: ""}"
>
{#if c === 0}
<span
class="font-semibold text-[12px] text-cladd-fg-soft"
>Total in {t.code}</span
>
{:else if col.key === "balance"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.balance })}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.due })}</span
>
{:else if col.key === "difference"}
<Amount
{...t}
amount={t.difference}
class="font-semibold"
/>
{/if}
</td>
{/each}
{@render sums([t], `Total in ${t.code}`)}
</tr>
{/each}
{/if}
</tfoot>
{/if}
</table>
@@ -690,7 +781,17 @@
{#if failure}
<span class="text-expense">{failure}</span>
{/if}
{#if active && otherCount > 0}
{#if perCurrency && (showInactive || otherCount > 0)}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
data-sveltekit-replacestate
href={inactiveHref()}
>{showInactive
? "Hide inactive accounts"
: `Show ${otherCount} inactive ${otherCount === 1 ? "account" : "accounts"}`}
<span class="kbd">i</span></a
>
{:else if active && otherCount > 0}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{type}"
@@ -0,0 +1,260 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { owed } from "#lib/accounts/ledger.js";
import { moves, sums } from "#lib/accounts/statement.js";
import { lists } from "#lib/api/lists.js";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatMoney, type Money, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { display } from "#lib/money/display.svelte.js";
import { pages, reportQueries } from "#lib/reports/queries.js";
import { goto } from "$app/navigation";
import { showUrl, typeOf } from "./links.js";
interface Line {
closing: number;
cur: Omit<Money, "amount">;
id: string;
income: number;
liability: boolean;
moved: number;
name: string;
opening: number;
other: number;
rows: number;
spent: number;
}
const CENT = 0.005;
const NUM = "num whitespace-nowrap text-right";
let {
accounts = [],
start,
end,
}: { accounts?: string[]; start: string; end: string } = $props();
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const balances = createQuery(() => ({
...reportQueries.balances(start, end),
placeholderData: keepPreviousData,
}));
const ledger = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end: end > start ? end : addDays(start, 1),
start,
type: "all",
}),
queryKey: ["accounts-audit", start, end],
}));
const range = $derived({ end, start });
const ready = $derived(
Boolean(assets.data && liabilities.data && balances.data && ledger.data)
);
const loading = $derived(
balances.isPlaceholderData || ledger.isPlaceholderData
);
function flip(a: Account): number {
const debt = owed(a);
const balance = toNumber(a.current_balance);
return Math.abs(debt) > CENT && Math.sign(balance) === -Math.sign(debt)
? -1
: 1;
}
function lineOf(item: Item<Account>, liability: boolean): Line {
const a = item.attributes;
const sign = liability ? flip(a) : 1;
const { rows, total } = sums(
moves(item.id, a.currency_code, ledger.data ?? []),
range
);
return {
closing: sign * (balances.data?.after.get(item.id) ?? 0),
cur: {
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
},
id: item.id,
income: total.income,
liability,
moved: total.movedIn + total.movedOut,
name: a.name,
opening: sign * (balances.data?.before.get(item.id) ?? 0),
other: total.opening + total.adjusted,
rows,
spent: total.spent,
};
}
const lines = $derived.by(() => {
if (!ready) {
return [];
}
const chosen = new Set(accounts);
return [...(assets.data ?? []), ...(liabilities.data ?? [])]
.filter((item) =>
chosen.size > 0 ? chosen.has(item.id) : item.attributes.active
)
.map((item) => lineOf(item, typeOf(item.attributes) === "liabilities"));
});
const byActivity = (x: Line, y: Line) =>
y.rows - x.rows || x.name.localeCompare(y.name);
const busy = $derived(lines.filter((l) => l.rows > 0));
const sections = $derived(
[
{
key: "asset",
lines: busy.filter((l) => !l.liability).sort(byActivity),
title: "Asset accounts",
words: ["Income", "Spent"],
},
{
key: "liability",
lines: busy.filter((l) => l.liability).sort(byActivity),
title: "Liabilities",
words: ["In", "Out"],
},
].filter((s) => s.lines.length > 0)
);
const ordered = $derived(sections.flatMap((s) => s.lines));
const idle = $derived(
lines
.filter((l) => l.rows === 0)
.sort((x, y) => x.name.localeCompare(y.name))
);
const adjusted = $derived(lines.some((l) => Math.abs(l.other) > CENT));
const href = (id: string) => `${showUrl(id)}/${start}/${end}`;
const nav = new ListNav<Line>({
id: "all-accounts",
items: () => ordered,
key: (line) => line.id,
open: (line) => goto(href(line.id)),
});
nav.use();
const money = (line: Line, amount: number, signed = false) =>
display.text([{ ...line.cur, amount }], signed);
const original = (line: Line, amount: number, signed = false) =>
display.total([{ ...line.cur, amount }])?.converted
? formatMoney({ ...line.cur, amount }, signed)
: undefined;
function tone(value: number, positive: string, negative = positive) {
if (Math.abs(value) < CENT) {
return "text-cladd-fg-softer";
}
return value > 0 ? positive : negative;
}
const real = (line: Line, value: number) =>
line.liability
? tone(value, "text-cladd-fg")
: tone(value, "text-income", "text-expense");
</script>
{#snippet cell(
line: Line,
amount: number,
extra: string,
signed = true
)}
<td class="{NUM} {extra}" title={original(line, amount, signed)}>
{money(line, amount, signed)}
</td>
{/snippet}
<Box body="p-0 overflow-x-auto" title="Accounts">
<table
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
? "opacity-60"
: ""}"
>
{#each sections as section (section.key)}
<thead>
<tr
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:whitespace-nowrap [&>th]:px-2 [&>th]:font-medium {section.key ===
"liability"
? "border-t"
: ""}"
>
<th class="text-cladd-fg">{section.title}</th>
<th class="w-14 max-md:hidden">Currency</th>
<th class="w-32 text-right">Opening</th>
<th class="w-28 text-right">{section.words[0]}</th>
<th class="w-28 text-right">{section.words[1]}</th>
<th class="w-28 text-right">Moved</th>
{#if adjusted}
<th class="w-28 text-right">Adjusted</th>
{/if}
<th class="w-32 text-right">Closing</th>
<th class="w-14 text-right max-md:hidden">Rows</th>
</tr>
</thead>
<tbody>
{#each section.lines as line (line.id)}
{@const index = ordered.indexOf(line)}
<tr
class="h-7 border-cladd-outline/60 border-b [&>td]:px-2 {nav.cursor ===
index
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
: "hover:bg-row-hover"}"
id={nav.rowId(index)}
onclick={(event) => nav.click(index, event)}
>
<td class="max-w-0 truncate max-md:min-w-32">
<a class="hover:underline" href={href(line.id)} title={line.name}
>{line.name}</a
>
</td>
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
{@render cell(line, line.opening, "", false)}
{@render cell(line, line.income, real(line, line.income))}
{@render cell(line, line.spent, real(line, line.spent))}
{@render cell(line, line.moved, tone(line.moved, "text-transfer"))}
{#if adjusted}
{@render cell(
line,
line.other,
tone(line.other, "text-cladd-fg")
)}
{/if}
{@render cell(line, line.closing, "font-semibold", false)}
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
</tr>
{/each}
</tbody>
{/each}
{#if idle.length > 0}
<tbody>
<tr class="text-[12px] text-cladd-fg-softer [&>td]:px-2 [&>td]:py-1.5">
<td colspan={adjusted ? 9 : 8}>
No activity:
{#each idle as line (line.id)}
<a
class="hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
href={href(line.id)}
>{line.name}</a
>
{/each}
</td>
</tr>
</tbody>
{/if}
</table>
{#if !ready}
<p class="px-3 py-6 text-center text-cladd-fg-soft">Loading accounts...</p>
{:else if lines.length === 0}
<p class="px-3 py-6 text-center text-cladd-fg-soft">
No accounts selected.
</p>
{/if}
</Box>
+25 -3
View File
@@ -8,6 +8,7 @@
currency,
money,
} from "#lib/components/dashboard/data.js";
import { display } from "#lib/money/display.svelte.js";
import type { Group } from "#lib/transactions/split.js";
const TOP = 6;
@@ -41,13 +42,29 @@
.filter((s) => s.type === kind)
);
const merged = $derived(
display.active &&
splits.every((s) => display.rate(s.currency_code) !== null)
);
const buckets = $derived<[string, Split[]][]>(
merged
? [[display.code, splits]]
: byCurrency(splits, (s) => s.currency_code ?? "")
);
const approx = $derived(
merged && splits.some((s) => s.currency_code !== display.code)
);
const sections = $derived(
byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
buckets.map(([code, list]) => {
const sums = new Map<string, Bar>();
for (const split of list) {
const [label, href] = pick(split);
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
bar.value += Number(split.amount);
const value = Number(split.amount);
bar.value += merged
? (display.convert(value, split.currency_code) ?? 0)
: value;
sums.set(label, bar);
}
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
@@ -63,7 +80,12 @@
},
]
: sorted;
return { bars, code, format: money(currency(code)) };
const format = money(currency(code));
return {
bars,
code,
format: approx ? (value: number) => `≈${format(value)}` : format,
};
})
);
</script>
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import {
type Action,
type Actions,
averageDays,
type Movement,
months,
overpaid,
owed,
stance,
} from "#lib/accounts/ledger.js";
import type { Account, Item } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { daysBetween, today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { showUrl as transactionUrl } from "#lib/transactions/split.js";
import { showUrl } from "./links.js";
import MonthBars from "./MonthBars.svelte";
const BUTTON =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
const PRIMARY = `${BUTTON} cladd-color-green text-cladd-primary`;
let {
item,
list,
actions,
keys,
rows = 5,
named = true,
current = false,
id,
onfocusin,
}: {
item: Item<Account>;
list: Movement[] | undefined;
actions: Actions | null;
keys: Record<keyof Actions, string>;
rows?: number;
named?: boolean;
current?: boolean;
id?: string;
onfocusin?: () => void;
} = $props();
const a = $derived(item.attributes);
const credit = $derived(a.liability_direction !== "debit");
const side = $derived(stance(a));
const amount = $derived(Math.abs(owed(a)));
const cur = $derived({
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
const format = (value: number) => formatMoney({ ...cur, amount: value });
const bars = $derived(list ? months(list) : []);
const sent = $derived(bars.reduce((s, m) => s + m.sent, 0));
const received = $derived(bars.reduce((s, m) => s + m.received, 0));
const average = $derived(list ? averageDays(list, credit) : null);
const last = $derived(list?.[0]);
const shown = $derived(list?.slice(0, rows) ?? []);
function ago(date: string) {
const days = daysBetween(date, today());
if (days === 0) {
return "today";
}
return days === 1 ? "yesterday" : `${days} days ago`;
}
</script>
{#snippet button(
action: Action,
key: string,
primary = false
)}
<a
aria-label={action.hint}
class={primary ? PRIMARY : BUTTON}
href={action.href}
>{action.label} <span class="kbd">{key}</span></a
>
{/snippet}
<article
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
? "outline-2 outline-(--ff-brand)"
: ""}"
{id}
{onfocusin}
>
<div class="flex flex-1 flex-col gap-3 p-3">
<div>
{#if named}
<h3 class="truncate font-semibold text-[15px] leading-snug">
<a class="hover:underline" href={showUrl(item.id)} title={a.name}
>{a.name}</a
>
</h3>
{/if}
{#if side === "settled"}
<p class="font-semibold text-[28px] text-cladd-fg-soft leading-tight">
Settled
</p>
{:else}
<p class="flex flex-wrap items-baseline gap-x-2">
<span class="text-[15px] text-cladd-fg-soft"
>{side === "owed" ? "Owes you" : "You owe"}</span
>
<span
class="num font-semibold text-[28px] leading-tight {side === "owed"
? "text-income"
: "text-expense"}"
>{format(amount)}</span
>
</p>
{/if}
{#if overpaid(a)}
<p class="text-[13px] text-cladd-fg-soft">
{credit ? "Paid you back" : "You paid back"}
<span class="num">{format(amount)}</span>
more than borrowed
</p>
{/if}
</div>
{#if last}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
>
<dt class="text-cladd-fg-soft">Last movement</dt>
<dd class="text-right">{formatDate(last.date)}, {ago(last.date)}</dd>
{#if average === null}
<dt class="col-span-2 text-cladd-fg-soft">No repayments yet</dt>
{:else}
<dt class="text-cladd-fg-soft">
{credit ? "Usually pays back in" : "You usually pay back in"}
</dt>
<dd class="text-right">
{average}
{average === 1 ? "day" : "days"}
</dd>
{/if}
{#if received > 0}
<dt class="text-cladd-fg-soft">Received in 12 months</dt>
<dd class="num text-right text-income">{format(received)}</dd>
{/if}
{#if sent > 0}
<dt class="text-cladd-fg-soft">Sent in 12 months</dt>
<dd class="num text-right text-expense">{format(sent)}</dd>
{/if}
</dl>
{/if}
{#if received + sent > 0}
<MonthBars {format} months={bars} />
{/if}
{#if rows > 0}
<table class="w-full table-fixed border-collapse text-[13px]">
<tbody>
{#each shown as m, i (`${m.group}-${i}`)}
<tr class="h-7 border-cladd-outline/60 border-t">
<td class="w-16 whitespace-nowrap text-cladd-fg-soft">
{formatDate(m.date, "short")}
</td>
<td class="truncate">
<a
class="hover:underline"
href={transactionUrl(m.group)}
title={m.description}
>{m.opening ? "Opening balance" : m.description}</a
>
</td>
<td class="w-28 whitespace-nowrap text-right">
<Amount
{...cur}
amount={m.into ? -m.amount : m.amount}
exact
signed
/>
</td>
</tr>
{:else}
<tr class="h-7 border-cladd-outline/60 border-t">
<td class="text-cladd-fg-soft" colspan="3">
{list ? "No movements yet" : ""}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if actions}
<footer
class="flex flex-col gap-1.5 border-cladd-outline border-t px-3 py-2"
>
<div class="flex flex-wrap items-center gap-1.5">
{#if actions.repay}
{@render button(actions.repay, keys.repay, true)}
{/if}
{@render button(actions.more, keys.more)}
{#if actions.offset}
{@render button(actions.offset, keys.offset)}
{/if}
</div>
{#if actions.offset?.line}
<p class="text-[12px] text-cladd-fg-soft">{actions.offset.line}</p>
{/if}
</footer>
{/if}
</article>
@@ -0,0 +1,61 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import {
type Action,
actionsFor,
isDebt,
movements,
} from "#lib/accounts/ledger.js";
import { all, lists } from "#lib/api/lists.js";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import type { Binding } from "#lib/keys.svelte.js";
import { useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import DebtCard from "./DebtCard.svelte";
const KEYS = { more: "+", offset: "=", repay: "S" };
let { item }: { item: Item<Account> } = $props();
const history = createQuery(() => ({
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${item.id}/transactions`),
queryKey: ["debt-history", item.id],
}));
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const list = $derived(
history.data ? movements(item, history.data) : undefined
);
const plan = $derived(
list && assets.data && liabilities.data
? actionsFor(
item,
list,
assets.data,
liabilities.data.filter((d) => isDebt(d) && d.attributes.active)
)
: null
);
const bind = (action: Action | null | undefined, keys: string): Binding[] =>
action
? [
{
group: "Debt",
keys,
label: action.hint,
run: () => goto(action.href),
},
]
: [];
useKeys(() => [
...bind(plan?.repay, KEYS.repay),
...bind(plan?.more, KEYS.more),
...bind(plan?.offset, KEYS.offset),
]);
</script>
<DebtCard actions={plan} {item} keys={KEYS} {list} named={false} rows={0} />
+355
View File
@@ -0,0 +1,355 @@
<script lang="ts">
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import {
type Action,
actionsFor,
isDebt,
movements,
owed,
type Stance,
stance,
} from "#lib/accounts/ledger.js";
import { api } from "#lib/api/client.js";
import { all, lists } from "#lib/api/lists.js";
import type {
Account,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import Money from "#lib/components/Money.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js";
import { formatDate, toNumber } from "#lib/format.js";
import type { Binding } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import DebtCard from "./DebtCard.svelte";
import {
editUrl,
LIABILITY_LABEL,
PERIOD_LABEL,
showUrl,
TYPE_ONE,
} from "./links.js";
const KEYS = { more: "+", offset: "o", repay: "s" };
const ALIASES = { more: "", offset: "=", repay: "S" };
const ORDER: Stance[] = ["owed", "owe", "settled"];
const CREATE = "/accounts/create/liabilities";
const liabilities = createQuery(() => ({
queryFn: () =>
all<Account>("/v1/accounts", {
active: "true",
date: today(),
type: "liabilities",
}),
queryKey: ["ledger-accounts"],
}));
const inactive = createQuery(() => ({
queryFn: () =>
api<Page<Account>>("/v1/accounts", {
query: { active: "false", limit: 1, type: "liabilities" },
}),
queryKey: ["accounts-count", "liabilities", false],
}));
const assets = createQuery(() => lists.accounts("asset"));
const debts = $derived((liabilities.data ?? []).filter(isDebt));
const others = $derived(
(liabilities.data ?? []).filter((item) => !isDebt(item))
);
const history = createQuery(() => ({
enabled: debts.length > 0,
queryFn: async () =>
new Map(
await Promise.all(
debts.map(
async (item) =>
[
item.id,
movements(
item,
await all<TransactionGroup>(
`/v1/accounts/${item.id}/transactions`
)
),
] as const
)
)
),
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
}));
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
const lastDate = (item: Item<Account>) =>
history.data?.get(item.id)?.[0]?.date ?? "";
const cards = $derived(
debts.toSorted(
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
)
);
const items = $derived([...cards, ...others]);
const index = $derived(new Map(items.map((item, i) => [item.id, i])));
const plans = $derived(
new Map(
cards.map((item) => [
item.id,
history.data && assets.data
? actionsFor(
item,
history.data.get(item.id) ?? [],
assets.data,
debts
)
: null,
])
)
);
interface Sum {
code: string;
decimals: number;
owe: number;
owed: number;
symbol: string;
}
const sums = $derived.by(() => {
const map = new Map<string, Sum>();
for (const { attributes: a } of debts) {
const code = a.currency_code ?? "";
const sum = map.get(code) ?? {
code,
decimals: a.currency_decimal_places ?? 2,
owe: 0,
owed: 0,
symbol: a.currency_symbol ?? code,
};
const position = owed(a);
if (position > 0) {
sum.owed += position;
} else {
sum.owe -= position;
}
map.set(code, sum);
}
return [...map.values()];
});
const owedToYou = $derived(sums.filter((s) => s.owed > 0));
const youOwe = $derived(sums.filter((s) => s.owe > 0));
function current(): Item<Account> | undefined {
return nav.current;
}
function bind(kind: keyof typeof KEYS, action?: Action | null): Binding[] {
if (!action) {
return [];
}
const run = () => goto(action.href);
const main = { group: "Debts", keys: KEYS[kind], label: action.hint, run };
const alias = ALIASES[kind];
return alias ? [main, { ...main, hidden: true, keys: alias }] : [main];
}
const nav = new ListNav<Item<Account>>({
edit: (item) => goto(editUrl(item.id)),
extra: (): Binding[] => {
const plan = plans.get(current()?.id ?? "");
return [
{
group: "List",
keys: "Enter",
label: "Open account",
run: () => {
const item = current();
if (item) {
goto(showUrl(item.id));
}
},
},
...bind("repay", plan?.repay),
...bind("more", plan?.more),
...bind("offset", plan?.offset),
{
group: "Accounts",
keys: "c",
label: `Create a new ${TYPE_ONE.liabilities}`,
run: () => goto(CREATE),
},
];
},
id: "ledger",
items: () => items,
key: (item) => item.id,
});
nav.use();
function rowClass(i: number) {
if (nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return "hover:bg-row-hover";
}
</script>
{#snippet totals(
label: string,
list: Sum[],
key: "owe" | "owed"
)}
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
<span class="text-[24px] leading-tight">
{#if list.length > 0}
<Money
class="font-semibold"
inline
parts={list.map((sum) => ({ ...sum, amount: sum[key] }))}
tone={key === "owed" ? "income" : "expense"}
/>
{:else}
<span class="num font-semibold text-cladd-fg-soft">0</span>
{/if}
</span>
</div>
{/snippet}
<PageHeader crumbs={[{ label: "Liabilities" }]} title="Accounts">
{#snippet actions()}
<a
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
href={CREATE}
title="Create a new liability c"
>
<Plus class="size-3.5" />
Create a new liability
</a>
{/snippet}
</PageHeader>
{#if liabilities.isPending}
<div class="h-40"></div>
{:else if liabilities.isError}
<p class="text-expense">{liabilities.error.message}</p>
{:else}
<div class="flex flex-col gap-3">
{#if debts.length > 0}
<section
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
>
{@render totals("Owed to you", owedToYou, "owed")}
{@render totals("You owe", youOwe, "owe")}
</section>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{#each cards as item (item.id)}
{@const i = index.get(item.id) ?? -1}
<DebtCard
actions={plans.get(item.id) ?? null}
current={nav.cursor === i}
id={nav.rowId(i)}
{item}
keys={KEYS}
list={history.data?.get(item.id)}
onfocusin={() => (nav.cursor = i)}
/>
{/each}
</div>
{:else}
<div
class="cladd-surface cladd-outline flex flex-col items-center gap-3 rounded-cladd-lg px-3 py-10 text-center"
>
<p class="font-semibold text-[15px]">No debts yet</p>
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
>Create a new liability</a
>
</div>
{/if}
{#if others.length > 0}
<Box body="p-0" title="Loans and other liabilities">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8 text-[12px] text-cladd-fg-soft">
<th class="px-3 font-semibold" scope="col">Name</th>
<th class="px-3 font-semibold max-md:hidden" scope="col">
Type of liability
</th>
<th class="px-3 font-semibold max-md:hidden" scope="col">
Interest
</th>
<th class="px-3 font-semibold max-md:hidden" scope="col">
Last activity
</th>
<th class="px-3 text-right font-semibold" scope="col">
Amount due
</th>
</tr>
</thead>
<tbody>
{#each others as item (item.id)}
{@const a = item.attributes}
{@const i = index.get(item.id) ?? -1}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
>
<td class="px-3">
<a
class="text-brand hover:underline dark:text-(--ff-series-1)"
href={showUrl(item.id)}
>{a.name}</a
>
</td>
<td class="px-3 max-md:hidden">
{LIABILITY_LABEL[a.liability_type ?? ""] ?? ""}
</td>
<td class="whitespace-nowrap px-3 max-md:hidden">
<span class="num">{toNumber(a.interest)}%</span>
<span class="text-cladd-fg-soft"
>{PERIOD_LABEL[a.interest_period ?? ""] ??
a.interest_period}</span
>
</td>
<td
class="whitespace-nowrap px-3 text-cladd-fg-soft max-md:hidden"
>
{a.last_activity
? formatDate(a.last_activity.slice(0, 10))
: "never"}
</td>
<td class="whitespace-nowrap px-3 text-right">
<Amount
amount={a.debt_amount}
code={a.currency_code}
decimals={a.currency_decimal_places}
symbol={a.currency_symbol}
tone="expense"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Box>
{/if}
{#if (inactive.data?.meta.pagination?.total ?? 0) > 0}
{@const count = inactive.data?.meta.pagination?.total ?? 0}
<a
class="self-start text-[13px] text-cladd-fg-soft hover:underline"
href="/inactive-accounts/liabilities"
>{count}
inactive {count === 1 ? "liability" : "liabilities"}</a
>
{/if}
</div>
{/if}
@@ -0,0 +1,148 @@
<script lang="ts" module>
export type Column =
| "name"
| "month"
| "share"
| "visits"
| "avg"
| "spark"
| "typical"
| "change"
| "last"
| "category";
</script>
<script lang="ts">
import type { Kind, Line, Merchant } from "#lib/accounts/merchants.js";
import { showUrl } from "#lib/components/accounts/links.js";
import { currency } from "#lib/components/dashboard/data.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, percent } from "#lib/format.js";
import MonthSpark from "./MonthSpark.svelte";
const LOUD = 0.15;
const SMALL = 0.005;
let {
col,
merchant: m,
kind,
compare,
max,
past,
note = "",
}: {
col: Column;
merchant: Merchant;
kind: Kind;
compare: boolean;
max: number;
past: number;
note?: string;
} = $props();
const money = (line: Line, amount: number, decimals?: number) => {
const cur = currency(line.code);
const text = formatMoney({
...cur,
amount,
decimals: decimals ?? cur.decimals,
});
return line.approx ? `≈${text}` : text;
};
function change(line: Line): [string, string] {
if (!compare || m.quiet) {
return ["", ""];
}
if (line.seen === 0) {
return ["new", "text-warn"];
}
if (line.ratio === null) {
return [`${line.seen} of ${past} months`, "text-cladd-fg-soft"];
}
const pct = Math.round(line.ratio * 100);
let sign = "";
if (pct !== 0) {
sign = pct > 0 ? "+" : "−";
}
const bad = kind === "expense" ? line.ratio > LOUD : line.ratio < -LOUD;
return [
`${sign}${percent(Math.abs(line.ratio))}`,
bad ? "text-expense" : "text-cladd-fg-soft",
];
}
const lastText = $derived(
m.last.slice(0, 4) === today().slice(0, 4)
? formatDate(m.last, "short")
: formatDate(m.last)
);
</script>
{#if col === "name"}
<a
class="block max-w-[9rem] truncate sm:max-w-[18rem] text-brand dark:text-(--ff-series-1) hover:underline"
href={showUrl(m.id)}
title={m.name}
>{m.name}</a
>
{:else if col === "month"}
{#each m.lines as line (line.code)}
<div class="num">{line.amount ? money(line, line.amount) : ""}</div>
{/each}
{:else if col === "share"}
{#if m.share > 0}
<div class="flex items-center gap-2">
<div class="h-2 w-16 shrink-0">
<div
class="h-full rounded-[2px] bg-brand/70"
style:width="{Math.max(2, (m.share / (max || 1)) * 100)}%"
></div>
</div>
<span class="num w-8 text-right text-cladd-fg-soft"
>{m.share < SMALL ? "<1%" : percent(m.share)}</span
>
</div>
{/if}
{:else if col === "visits"}
<span class="num">{m.visits || ""}</span>
{:else if col === "avg"}
{#each m.lines as line (line.code)}
<div class="num text-cladd-fg-soft">
{line.visits ? money(line, line.amount / line.visits, 0) : ""}
</div>
{/each}
{:else if col === "spark"}
{#each m.lines as line (line.code)}
<div class="flex h-[1lh] items-center justify-end">
<MonthSpark typical={line.typical} values={line.months} />
</div>
{/each}
{:else if col === "typical"}
{#each m.lines as line (line.code)}
{@const value = compare && !m.quiet ? line.base : line.typical}
<div class="num text-cladd-fg-soft">
{value ? money(line, value, 0) : ""}
</div>
{/each}
{:else if col === "change"}
{#each m.lines as line (line.code)}
{@const [text, tone] = change(line)}
<div class="num {tone}">{text}</div>
{/each}
{:else if col === "last"}
<span class="num text-cladd-fg-soft">{lastText}</span>
{:else}
<span class="flex max-w-[16rem] items-baseline gap-1.5">
<span class="truncate {m.category ? "" : "text-cladd-fg-softer"}"
>{m.category ?? "none"}</span
>
{#if m.others}
<span class="shrink-0 text-cladd-fg-softer">+{m.others}</span>
{/if}
{#if note}
<span class="shrink-0 text-cladd-fg-soft">{note}</span>
{/if}
</span>
{/if}
@@ -0,0 +1,707 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import {
byCategory,
type Kind,
type Merchant,
SORT_KEYS,
type SortKey,
sortBy,
type Total,
} from "#lib/accounts/merchants.js";
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import { currency } from "#lib/components/dashboard/data.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import SuggestInput from "#lib/components/transactions/SuggestInput.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, percent } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { versusText } from "#lib/reports/typical.js";
import {
type Outcome,
patchFor,
type Targets,
updateGroups,
} from "#lib/transactions/mass.js";
import type { Suggestion } from "#lib/transactions/query.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { editUrl, openOld, showUrl, TYPE_ONE, TYPE_TITLE } from "./links.js";
import MerchantCell, { type Column } from "./MerchantCell.svelte";
const NONE = "\u0000";
const SAME = 0.03;
const WIDE: Column[] = [
"share",
"visits",
"avg",
"spark",
"typical",
"last",
"category",
];
let { kind }: { kind: Kind } = $props();
const data = useMerchants(() => kind);
const noun = $derived(kind === "expense" ? "Merchants" : "Payers");
const createHref = $derived(`/accounts/create/${kind}`);
const params = $derived(page.url.searchParams);
const sort = $derived<SortKey>(
SORT_KEYS.find((key) => key === params.get("sort")) ?? "month"
);
const grouped = $derived(params.get("group") === "category");
const quietParam = $derived(params.has("quiet"));
let filter = $state(page.url.searchParams.get("filter") ?? "");
let filterInput = $state<HTMLInputElement>();
let menu = $state<Merchant | null>(null);
let armed = $state(false);
let failure = $state("");
let picking = $state<Merchant[] | null>(null);
let text = $state("");
let input = $state<HTMLInputElement>();
let progress = $state("");
let notes = $state<Record<string, string>>({});
const day = $derived(data.day);
const compare = $derived(day !== null);
const current = $derived(day !== null && day < 31);
const asOf = $derived(formatDate(today(), "short"));
const needle = $derived(filter.trim().toLowerCase());
const all = $derived(data.board?.merchants ?? []);
const matching = $derived(
needle
? all.filter((m) =>
`${m.name} ${m.category ?? ""}`.toLowerCase().includes(needle)
)
: all
);
const order = $derived(sortBy(sort));
const active = $derived(matching.filter((m) => !m.quiet).sort(order));
const quiet = $derived(matching.filter((m) => m.quiet).sort(order));
const quietOpen = $derived(quietParam || needle !== "");
const sections = $derived(grouped ? byCategory(active, "No category") : null);
const rows = $derived([
...(sections ? sections.flatMap((s) => s.merchants) : active),
...(quietOpen ? quiet : []),
]);
const index = $derived(new Map(rows.map((m, i) => [m.id, i])));
const max = $derived(Math.max(0, ...all.map((m) => m.share)));
const activeCount = $derived(all.filter((m) => !m.quiet).length);
const categorised = $derived(all.some((m) => m.category));
const columns = $derived<{ key: Column; label: string; sort?: SortKey }[]>([
{
key: "name",
label: kind === "expense" ? "Merchant" : "Payer",
sort: "name",
},
{
key: "month",
label: current ? "This month" : "In period",
sort: "month",
},
{ key: "share", label: "Share" },
{
key: "visits",
label: kind === "expense" ? "Visits" : "Payments",
sort: "visits",
},
{ key: "avg", label: "Average" },
{ key: "spark", label: "12 months", sort: "year" },
{ key: "typical", label: current ? `Typical by ${asOf}` : "Typical month" },
{ key: "change", label: "vs typical" },
{ key: "last", label: "Last", sort: "last" },
...(categorised ? [{ key: "category" as const, label: "Category" }] : []),
]);
const NUMERIC = new Set<Column>([
"month",
"visits",
"avg",
"spark",
"typical",
"change",
"last",
]);
const categories = createQuery(() => ({
...lists.categories(),
enabled: picking !== null,
}));
const suggestions = $derived.by<Suggestion[]>(() => {
const names = (categories.data ?? []).map((c) => c.attributes.name);
const query = text.trim();
const found: Suggestion[] = fuzzyFilter(names, query, (n) => n, 8).map(
(name) => ({ id: name, insert: name, label: name })
);
if (query && !names.some((n) => n.toLowerCase() === query.toLowerCase())) {
found.push({
hint: "new",
id: `new:${query}`,
insert: query,
label: query,
});
}
if (!query) {
found.unshift({ id: NONE, insert: NONE, label: "(no category)" });
}
return found;
});
const journals = (targets: Targets) =>
[...targets.values()].reduce((n, set) => n + (set?.size ?? 0), 0);
function merge(list: Merchant[]): Targets {
const targets: Targets = new Map();
for (const m of list) {
for (const [group, set] of m.targets) {
targets.set(
group,
new Set([...(targets.get(group) ?? []), ...(set ?? [])])
);
}
}
return targets;
}
const pickCount = $derived(picking ? journals(merge(picking)) : 0);
async function pick() {
const list = chosen.filter((m) => m.targets.size > 0);
if (list.length === 0) {
notes = Object.fromEntries(
chosen.map((m) => [m.id, `nothing in ${period.label}`])
);
return;
}
picking = list;
text = "";
progress = "";
await tick();
input?.focus();
}
function cancel() {
if (!progress) {
picking = null;
}
}
async function apply(raw: string) {
const list = picking;
if (!list || progress) {
return;
}
const value = raw === NONE ? null : raw.trim() || null;
const say = (o: Outcome) => {
progress = `Setting “${value ?? "no category"}”: ${o.done} of ${o.total}`;
};
const outcome = await updateGroups(
merge(list),
patchFor({ id: null, mode: "category", value }),
say
);
await invalidate();
const failed = outcome.errors.join("; ");
const next = { ...notes };
for (const m of list) {
next[m.id] = failed
? `failed: ${failed}`
: `${value ? "set" : "cleared"} on ${journals(m.targets)}`;
}
notes = next;
progress = "";
picking = null;
nav.clear();
}
let pending = "";
function setParam(name: string, value: string | null) {
const url = new URL(pending || page.url.href);
if (value === null) {
url.searchParams.delete(name);
} else {
url.searchParams.set(name, value);
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
pending = url.href;
goto(url, { replace: true, reset: false }).finally(() => {
if (pending === url.href) {
pending = "";
}
});
}
const setSort = (key: SortKey) =>
setParam("sort", key === "month" ? null : key);
const toggleGroup = () => setParam("group", grouped ? null : "category");
const toggleQuiet = () => setParam("quiet", quietParam ? null : "1");
const nav = new ListNav<Merchant>({
edit: (m) => goto(editUrl(m.id)),
extra: () => [
{
group: noun,
keys: "/",
label: `Filter ${noun.toLowerCase()}`,
run: () => {
filterInput?.focus();
filterInput?.select();
},
},
{
group: noun,
keys: "c",
label: `Create a new ${TYPE_ONE[kind]}`,
run: () => goto(createHref),
},
...SORT_KEYS.map((key, i) => ({
group: noun,
keys: String(i + 1),
label: `Sort by ${columns.find((c) => c.sort === key)?.label.toLowerCase()}`,
run: () => setSort(key),
})),
{
group: noun,
keys: ";",
label: grouped ? "Ungroup" : "Group by category",
run: toggleGroup,
},
{
group: noun,
keys: "i",
label: quietParam
? `Hide quiet ${noun.toLowerCase()}`
: `Show quiet ${noun.toLowerCase()}`,
run: toggleQuiet,
},
{
group: noun,
keys: "s c",
label: "Set category on their transactions",
run: pick,
},
],
id: "merchants",
items: () => rows,
key: (m) => m.id,
open: (m) => goto(showUrl(m.id)),
selectable: true,
});
nav.use();
const chosen = $derived.by(() => {
if (nav.selected.size > 0) {
return all.filter((m) => nav.selected.has(m.id));
}
return nav.current ? [nav.current] : [];
});
const anchorId = $derived(
picking
? (rows.find((m) => picking?.some((p) => p.id === m.id)) ?? nav.current)
?.id
: null
);
function onfilter() {
nav.cursor = -1;
setParam("filter", filter || null);
}
function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) {
filter = "";
onfilter();
} else if (event.key === "Enter" && rows[0]) {
event.preventDefault();
filterInput?.blur();
nav.move(0);
}
}
function oncontextmenu(event: MouseEvent) {
const tr = (event.target as Element).closest("tr[data-id]");
armed = false;
menu = rows.find((m) => m.id === tr?.getAttribute("data-id")) ?? null;
if (menu) {
nav.cursor = index.get(menu.id) ?? -1;
}
}
async function remove(m: Merchant) {
failure = "";
try {
await api(`/v1/accounts/${m.id}`, { method: "DELETE" });
await invalidate();
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
}
}
function rowClass(m: Merchant, i: number) {
if (nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return nav.isSelected(m) ? "bg-row-selected" : "hover:bg-row-hover";
}
function cellClass(key: Column) {
const align = NUMERIC.has(key) ? "text-right" : "";
const wide = WIDE.includes(key) ? "hidden md:table-cell" : "";
return `${align} ${wide}`;
}
function money(t: Total) {
const shown = formatMoney({ ...currency(t.code), amount: t.amount });
return t.approx ? `≈${shown}` : shown;
}
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < SAME) {
return "text-cladd-fg-soft";
}
const bad = kind === "expense" ? ratio > 0 : ratio < 0;
return bad ? "text-expense" : "text-cladd-fg-soft";
}
</script>
{#snippet sums(
list: Total[]
)}
{#each list as t (t.code)}
<div class="num">{money(t)}</div>
{/each}
{/snippet}
{#snippet row(
m: Merchant
)}
{@const i = index.get(m.id) ?? -1}
<tr
class="border-cladd-outline/60 border-t align-top {rowClass(m, i)}"
data-id={m.id}
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
>
{#each columns as col (col.key)}
<td class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {cellClass(col.key)}">
<MerchantCell
col={col.key}
{compare}
{kind}
{max}
merchant={m}
note={notes[m.id]}
past={data.board?.past ?? 0}
/>
</td>
{/each}
</tr>
{#if anchorId === m.id && picking}
<tr class="bg-row-selected/50">
<td class="px-3 py-1.5" colspan={columns.length}>
<div class="flex flex-wrap items-center gap-2 text-[12px]">
{#if progress}
<span aria-live="polite">{progress}</span>
{:else}
<label class="text-cladd-fg-soft" for="merchant-category"
>Category for {pickCount}
{pickCount === 1 ? "transaction" : "transactions"}
at
{picking.length === 1
? picking[0].name
: `${picking.length} ${noun.toLowerCase()}`}:</label
>
<SuggestInput
class="w-72"
id="merchant-category"
onaccept={(item) => {
apply(item.insert);
return true;
}}
onblur={cancel}
oncancel={cancel}
onsubmit={() => apply(text)}
placeholder="Type to filter"
{suggestions}
bind:input
bind:value={text}
/>
<span class="text-cladd-fg-softer"
><span class="kbd">↵</span>
apply <span class="kbd">Esc</span>
cancel</span
>
{/if}
</div>
</td>
</tr>
{/if}
{/snippet}
<PageHeader crumbs={[{ label: TYPE_TITLE[kind] }]} title="Accounts">
{#snippet actions()}
<a
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
href={createHref}
title="Create a new {TYPE_ONE[kind]} c"
>
<Plus class="size-3.5" />
Create a new {TYPE_ONE[kind]}
</a>
{/snippet}
</PageHeader>
<Box body="p-0" title="{noun} in {period.label}">
{#snippet tools()}
<button
aria-pressed={grouped}
class="hidden h-7 sm:inline-flex items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] {grouped
? "bg-row-selected text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={toggleGroup}
type="button"
>
By category <span class="kbd">;</span>
</button>
<input
aria-label="Filter {noun.toLowerCase()}"
class="cladd-cut h-7 w-36 rounded-cladd-sm px-2 text-[12px] outline-none placeholder:text-cladd-fg-softer sm:w-56"
oninput={onfilter}
onkeydown={onfilterkey}
placeholder="Filter /"
type="search"
bind:this={filterInput}
bind:value={filter}
>
{/snippet}
{#if data.error}
<p class="p-3 text-expense">{data.error.message}</p>
{:else if data.loading || !data.board}
<div class="h-40"></div>
{:else}
{@const b = data.board}
<div
class="flex flex-col gap-0.5 border-cladd-outline border-b px-3 py-2 text-[13px]"
>
<p class="flex flex-wrap items-baseline gap-x-4 gap-y-0.5">
<span class="text-cladd-fg-soft"
>{kind === "expense" ? "Spent" : "Earned"}</span
>
{#each b.totals as t (t.code)}
<span class="whitespace-nowrap">
<span class="num font-semibold text-[15px]">{money(t)}</span>
{#if t.ratio !== null}
<span class={tone(t.ratio)}
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
>
{/if}
</span>
{:else}
<span class="text-cladd-fg-soft">nothing yet</span>
{/each}
</p>
<p class="text-[12px] text-cladd-fg-soft">
{activeCount}
of {all.length}
{noun.toLowerCase()}
{#if activeCount > 5}
· top 5 take {percent(b.top)}
{/if}
{#if b.fresh > 0}
· {b.fresh} new
{/if}
</p>
</div>
<ContextMenu>
<ContextMenuTrigger
class="block {picking ? "overflow-visible" : "overflow-x-auto"}"
{oncontextmenu}
>
<table
class="w-full border-collapse text-[13px] transition-opacity {data.stale
? "opacity-60"
: ""}"
>
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8">
{#each columns as col (col.key)}
<th
aria-sort={col.sort && sort === col.sort
? "descending"
: undefined}
class="whitespace-nowrap px-2 font-semibold text-[12px] text-cladd-fg-soft sm:px-3 {cellClass(
col.key
)}"
scope="col"
>
{#if col.sort}
{@const n = SORT_KEYS.indexOf(col.sort) + 1}
<button
class="inline-flex items-center gap-1 hover:text-cladd-fg {sort ===
col.sort
? "text-cladd-fg"
: ""}"
onclick={() => col.sort && setSort(col.sort)}
title="Sort {n}"
type="button"
>
{col.label}
{#if sort === col.sort}
<ArrowDown class="size-3" />
{:else}
<span
class="font-normal text-[11px] text-cladd-fg-softer"
>{n}</span
>
{/if}
</button>
{:else}
{col.label}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#if sections}
{#each sections as s (s.key)}
<tr class="border-cladd-outline border-t-2 align-top">
<th
class="px-3 py-1 text-left font-semibold"
scope="rowgroup"
>
{s.label}
<span class="font-normal text-cladd-fg-soft"
>{s.merchants.length}</span
>
</th>
<td class="px-3 py-1 text-right font-semibold">
{@render sums(s.sums)}
</td>
<td class="hidden px-3 py-1 md:table-cell">
<span class="num text-cladd-fg-soft"
>{percent(s.share)}</span
>
</td>
<td class="hidden px-3 py-1 text-right md:table-cell">
<span class="num">{s.visits}</span>
</td>
<td class="px-3 py-1" colspan={columns.length - 4}></td>
</tr>
{#each s.merchants as m (m.id)}
{@render row(m)}
{/each}
{/each}
{:else}
{#each active as m (m.id)}
{@render row(m)}
{/each}
{/if}
{#if quiet.length > 0}
<tr class="border-cladd-outline border-t-2">
<td class="px-3 py-1" colspan={columns.length}>
<button
class="inline-flex items-center gap-1.5 text-[12px] text-cladd-fg-soft hover:text-cladd-fg"
onclick={toggleQuiet}
type="button"
>
{quietOpen ? "Quiet" : `${quiet.length} quiet`}
{current ? "this month" : `in ${period.label}`}
{#if !needle}
<span class="kbd">i</span>
{/if}
</button>
</td>
</tr>
{#if quietOpen}
{#each quiet as m (m.id)}
{@render row(m)}
{/each}
{/if}
{/if}
{#if rows.length === 0 && needle}
<tr>
<td
class="px-3 py-6 text-center text-cladd-fg-soft"
colspan={columns.length}
>
Nothing matches “{filter}”. Press Escape to clear.
</td>
</tr>
{/if}
</tbody>
</table>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menu}
{@const target = menu}
<ContextMenuItem onSelect={() => goto(showUrl(target.id))}>
Show <ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => goto(editUrl(target.id))}>
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={pick}>
Set category <ContextMenuShortcut>s c</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
if (armed) {
remove(target);
return;
}
event.preventDefault();
armed = true;
}}
variant="destructive"
>
{armed ? `Press again to delete ${target.name}` : "Delete"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => openOld(showUrl(target.id))}>
Open in old UI
</ContextMenuItem>
{:else}
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
href={createHref}
>Create a new {TYPE_ONE[kind]}</a
>
{#if failure}
<span class="text-expense">{failure}</span>
{/if}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{kind}"
>Inactive {TYPE_TITLE[kind].toLowerCase()}</a
>
{/snippet}
</Box>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { Kind } from "#lib/accounts/merchants.js";
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
import Box from "#lib/components/Box.svelte";
import { indexUrl } from "./links.js";
import MerchantCell, { type Column } from "./MerchantCell.svelte";
const COLUMNS: Column[] = ["name", "month", "share", "change"];
let { kind, limit = 8 }: { kind: Kind; limit?: number } = $props();
const data = useMerchants(() => kind);
const all = $derived(data.board?.merchants ?? []);
const top = $derived(all.filter((m) => !m.quiet).slice(0, limit));
const href = $derived(indexUrl(kind));
</script>
<Box
body="p-0"
{href}
title={kind === "expense" ? "Where it went" : "Where it came from"}
>
{#if data.error}
<p class="p-3 text-expense">{data.error.message}</p>
{:else if data.board && top.length > 0}
<table class="w-full border-collapse text-[13px]">
<thead class="sr-only">
<tr>
<th scope="col">{kind === "expense" ? "Merchant" : "Payer"}</th>
<th scope="col">This period</th>
<th scope="col">Share</th>
<th scope="col">vs typical</th>
</tr>
</thead>
<tbody>
{#each top as m (m.id)}
<tr
class="border-cladd-outline/60 border-t align-top first:border-t-0"
>
{#each COLUMNS as col (col)}
<td
class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {col === "name"
? ""
: "text-right"} {col === "share"
? "hidden sm:table-cell"
: ""}"
>
<MerchantCell
{col}
compare={data.day !== null}
{kind}
max={top[0]?.share ?? 0}
merchant={m}
past={data.board.past}
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else if data.board}
<p class="p-3 text-cladd-fg-soft">Nothing in this period.</p>
{:else}
<div class="h-40"></div>
{/if}
{#snippet footer()}
<a class="text-brand dark:text-(--ff-series-1) hover:underline" {href}
>See all {all.length}</a
>
{/snippet}
</Box>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Month } from "#lib/accounts/ledger.js";
import { currentLocale } from "#lib/format.js";
let {
months,
format,
}: {
months: Month[];
format: (value: number) => string;
} = $props();
const max = $derived(
Math.max(1, ...months.flatMap((m) => [m.sent, m.received]))
);
const letter = (month: string) =>
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
month: "narrow",
});
const name = (month: string) =>
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
month: "short",
year: "numeric",
});
const height = (value: number) =>
value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0";
const title = (m: Month) =>
`${name(m.month)}: received ${format(m.received)}, sent ${format(m.sent)}`;
</script>
<div
aria-label="Received and sent per month"
class="grid grid-cols-12 gap-x-1"
role="img"
>
{#each months as m (m.month)}
<div class="flex h-8 items-end" title={title(m)}>
<div
class="w-full rounded-t-[2px] bg-income"
style:height={height(m.received)}
></div>
</div>
{/each}
<div class="col-span-12 h-px bg-cladd-outline"></div>
{#each months as m (m.month)}
<div class="flex h-8 items-start" title={title(m)}>
<div
class="w-full rounded-b-[2px] bg-expense"
style:height={height(m.sent)}
></div>
</div>
{/each}
{#each months as m (m.month)}
<span class="pt-0.5 text-center text-[10px] text-cladd-fg-softer"
>{letter(m.month)}</span
>
{/each}
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
let {
values,
typical = 0,
width = 72,
height = 14,
}: {
values: number[];
typical?: number;
width?: number;
height?: number;
} = $props();
const GAP = 1;
const max = $derived(Math.max(typical, ...values) || 1);
const step = $derived(width / Math.max(1, values.length));
const y = (value: number) => height - (value / max) * height;
</script>
<svg aria-hidden="true" class="block shrink-0" {height} {width}>
{#each values as value, i (i)}
{#if value > 0}
<rect
class={i === values.length - 1
? "fill-brand"
: "fill-cladd-fg-softer opacity-60"}
height={Math.max(1, height - y(value))}
width={Math.max(1, step - GAP)}
x={i * step}
y={Math.min(y(value), height - 1)}
/>
{/if}
{/each}
{#if typical > 0}
<line
class="stroke-cladd-fg"
stroke-dasharray="2 2"
stroke-width="1"
x1="0"
x2={width}
y1={y(typical) + 0.5}
y2={y(typical) + 0.5}
/>
{/if}
</svg>
+33 -6
View File
@@ -16,6 +16,7 @@
type ViewRange,
} from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period, periodLabel } from "#lib/period.svelte.js";
const COUNT = 12;
@@ -50,6 +51,7 @@
}));
interface Flow {
approx?: boolean;
code: string;
earned: number;
spent: number;
@@ -76,12 +78,34 @@
}
return map;
});
const merged = $derived.by(() => {
if (!display.active) {
return null;
}
const map = new Map<string, Flow[]>();
for (const [key, list] of byStart) {
const flow: Flow = { code: display.code, earned: 0, spent: 0 };
for (const f of list) {
const rate = display.rate(f.code);
if (rate === null) {
return null;
}
flow.earned += f.earned * rate;
flow.spent += f.spent * rate;
flow.approx ||= f.code !== display.code;
}
map.set(key, [flow]);
}
return map;
});
const shown = $derived(merged ?? byStart);
const main = $derived(merged ? display.code : code);
const scale = $derived(
Math.max(
1,
...[...byStart.values()].flatMap((list) =>
...[...shown.values()].flatMap((list) =>
list
.filter((f) => f.code === code)
.filter((f) => f.code === main)
.map((f) => Math.max(f.earned, f.spent))
)
)
@@ -95,7 +119,7 @@
class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6"
>
{#each blocks as block (block.start)}
{@const list = (byStart.get(block.start) ?? []).filter(
{@const list = (shown.get(block.start) ?? []).filter(
(f) => f.earned || f.spent
)}
<li class="border-cladd-outline/60 border-r border-b">
@@ -117,17 +141,20 @@
{/if}
{#each list as flow (flow.code)}
{@const fmt = money(currency(flow.code))}
{@const mark = flow.approx ? "≈" : ""}
<span
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
>
{#if flow.earned}
<span class="text-income">+{fmt(flow.earned)}</span>
<span class="text-income">{mark}+{fmt(flow.earned)}</span>
{/if}
{#if flow.spent}
<span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
<span class="ml-auto text-expense"
>{mark}−{fmt(flow.spent)}</span
>
{/if}
</span>
{#if flow.code === code}
{#if flow.code === main}
<span aria-hidden="true" class="flex h-1 gap-0.5">
<span class="flex flex-1 justify-end">
<span
+19 -6
View File
@@ -56,6 +56,7 @@
let busy = $state(false);
let status = $state("");
let failure = $state("");
let pressed = $state(false);
const account = createQuery(() => ({
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
@@ -179,6 +180,17 @@
const canCorrect = $derived(Boolean(reconciliationId));
const correcting = $derived(correct && canCorrect && difference !== 0);
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
const hint = $derived.by(() => {
if (!pressed) {
return "";
}
if (!ready) {
return "Balances are still loading";
}
return selected.length === 0 && !correcting
? "Tick the transactions to clear first"
: "";
});
function setDates(nextStart: string, nextEnd: string) {
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
@@ -241,7 +253,8 @@
}
async function store() {
if (busy || !ready || (selected.length === 0 && !correcting)) {
pressed = true;
if (busy || hint) {
return;
}
busy = true;
@@ -338,7 +351,7 @@
>
</td>
<td class="whitespace-nowrap px-3 text-right">
<Amount amount={row.amount} {...cur} />
<Amount amount={row.amount} exact {...cur} />
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.source_name}
@@ -525,10 +538,7 @@
</fieldset>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Button
disabled={busy || !ready || (selected.length === 0 && !correcting)}
onclick={store}
type="button"
<Button disabled={busy} onclick={store} type="button"
>Store reconciliation</Button
>
<span class="flex gap-1">
@@ -536,6 +546,9 @@
<span class="kbd">{key}</span>
{/each}
</span>
{#if hint}
<span class="text-[12px] text-expense" role="alert">{hint}</span>
{/if}
</div>
{#if status}
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
@@ -0,0 +1,280 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { owed } from "#lib/accounts/ledger.js";
import {
balanceAt,
chartRange,
daily,
monthsOf,
moves,
reconciledThrough,
sideOf,
signOf,
sums,
terms,
viewOf,
} from "#lib/accounts/statement.js";
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Account, Item, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import LineChart from "#lib/components/charts/LineChart.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { addDays, addMonths, contains, today } from "#lib/dates.js";
import { currentLocale, formatDate, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { sectionOf } from "#lib/transactions/ledger.js";
import { goto } from "$app/navigation";
import DebtPanel from "./DebtPanel.svelte";
import { canReconcile, reconcileUrl, showUrl, typeOf } from "./links.js";
import StatementHeader from "./StatementHeader.svelte";
import StatementMonths from "./StatementMonths.svelte";
const MOVED_KEY = "ffui.account.moved";
const DAYS_KEY = "ffui.account.days";
const TOGGLE =
"inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg";
const LINK = "text-brand dark:text-(--ff-series-1) hover:underline";
let { item }: { item: Item<Account> } = $props();
let moved = $state(localStorage.getItem(MOVED_KEY) !== "0");
let days = $state(localStorage.getItem(DAYS_KEY) === "1");
const now = today();
const id = $derived(item.id);
const a = $derived(item.attributes);
const liability = $derived(typeOf(a) === "liabilities");
const side = $derived(sideOf(a, liability));
const sign = $derived(signOf(side));
const cur = $derived({
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
const view = $derived(viewOf(cur));
const chart = $derived(chartRange(period.range, now));
const span = $derived({
end: [chart.end, period.end, now].sort().at(-1) ?? now,
start: [chart.start, period.start].sort()[0],
});
const current = createQuery(() => ({
enabled: !liability,
queryFn: () =>
api<One<Account>>(`/v1/accounts/${id}`, { query: { date: now } }),
queryKey: ["account", id, now],
}));
const history = createQuery(() =>
liability
? {
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
queryKey: ["debt-history", id],
}
: {
placeholderData: keepPreviousData,
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, span),
queryKey: ["account-history", id, span.start, span.end],
}
);
const anchor = $derived.by(() => {
if (liability) {
return { balance: owed(a), date: now };
}
const value = current.data?.data.attributes.current_balance;
return value === undefined ? null : { balance: toNumber(value), date: now };
});
const ready = $derived(
Boolean(anchor && history.data) && !history.isPlaceholderData
);
const list = $derived(
history.data ? moves(id, a.currency_code, history.data) : []
);
const at = (day: string) =>
anchor ? sign * balanceAt(list, anchor, day) : 0;
const opening = $derived(at(addDays(period.start, -1)));
const closing = $derived(at(period.end));
const parts = $derived(terms(side, sums(list, period.range).total));
const months = $derived(monthsOf(chart, list));
const line = $derived(
anchor
? daily(list, anchor, chart).map((p) => ({
x: p.x,
y: sign * p.y * view.rate,
}))
: []
);
const groups = $derived(
(history.data ?? []).filter((g) =>
g.attributes.transactions.some((s) => contains(period.range, s.date))
)
);
const through = $derived(reconciledThrough(list));
const monthEnd = (month: string) => addDays(addMonths(month, 1), -1);
const selected = $derived(
period.start.endsWith("-01") && period.end === monthEnd(period.start)
? period.start
: null
);
const thisMonth = `${now.slice(0, 7)}-01`;
const lineTitle = $derived(side === "asset" ? "Balance" : "Debt balance");
const series = $derived([
{
area: true,
format: (value: number) => view.format(value),
key: "balance",
label: lineTitle,
points: line,
},
]);
const monthYear = (value: string) =>
new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString(
currentLocale(),
{ month: "short", year: "2-digit" }
);
function pick(month: string) {
goto(`${showUrl(id)}/${month}/${monthEnd(month)}`);
}
function toggleMoved() {
moved = !moved;
localStorage.setItem(MOVED_KEY, moved ? "1" : "0");
}
function toggleDays() {
days = !days;
localStorage.setItem(DAYS_KEY, days ? "1" : "0");
}
useKeys(() => [
...(item.attributes.liability_type === "debt"
? []
: [
{
group: "Account",
keys: "t",
label: moved ? "Hide moved money" : "Show moved money",
run: toggleMoved,
},
]),
{
group: "Account",
keys: ";",
label: days ? "Hide day subtotals" : "Show day subtotals",
run: toggleDays,
},
]);
</script>
<div class="flex flex-col gap-3">
<StatementHeader
{closing}
{cur}
empty={!(anchor && history.data)}
end={period.end}
loading={!ready}
{opening}
{side}
start={period.start}
terms={parts}
/>
<div
class="grid gap-3 {a.liability_type === "debt"
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
>
{#if a.liability_type === "debt"}
<DebtPanel {item} />
{:else}
<Box title="In and out by month">
{#snippet tools()}
<button class={TOGGLE} onclick={toggleMoved} type="button">
{moved ? "Hide moved" : "Show moved"}
<span class="kbd">t</span>
</button>
{/snippet}
<div class="transition-opacity {ready ? "" : "opacity-60"}">
<StatementMonths
{cur}
{months}
{moved}
onselect={pick}
partial={thisMonth}
{selected}
{side}
/>
</div>
</Box>
{/if}
<Box title={lineTitle}>
<div class="transition-opacity {ready ? "" : "opacity-60"}">
{#if line.length > 0}
<LineChart
axisFormat={view.axis}
height={236}
label="{lineTitle} of {a.name} by day"
marker={contains(chart, now) ? now : undefined}
{series}
toggle={false}
xFormat={monthYear}
/>
{:else}
<div class="h-[264px]"></div>
{/if}
</div>
</Box>
</div>
{#if a.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap text-cladd-fg">{a.notes}</p>
</Box>
{/if}
<Box body="p-0" title="Statement for {period.label}">
{#snippet tools()}
<span class="num text-[12px] text-cladd-fg-soft max-sm:hidden"
>{groups.length}
{groups.length === 1 ? "transaction" : "transactions"}</span
>
<button
aria-pressed={days}
class="{TOGGLE} {days ? "bg-cladd-surface-next text-cladd-fg" : ""}"
onclick={toggleDays}
type="button"
>
Day subtotals
<span class="kbd">;</span>
</button>
{/snippet}
<TransactionTable
context={{ accountId: id }}
empty="No transactions in this period."
{groups}
id="account-tx"
loading={history.isFetching}
section={days ? (group) => sectionOf("day", group) : undefined}
/>
{#snippet footer()}
{#if canReconcile(item)}
<span class="text-cladd-fg-soft">
{through
? `Reconciled through ${formatDate(through)}`
: `Not reconciled since ${formatDate(span.start, "month")}`}
</span>
<a class={LINK} href={reconcileUrl(id)}
>Reconcile {period.label} <span class="kbd">R</span></a
>
{/if}
<a class="{LINK} ml-auto" href="{showUrl(id)}/all"
>Show all transactions without grouping them by date</a
>
{/snippet}
</Box>
</div>
@@ -0,0 +1,130 @@
<script lang="ts">
import type { Side, Term } from "#lib/accounts/statement.js";
import { addDays } from "#lib/dates.js";
import { formatDate, formatMoney, type Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
let {
terms,
opening,
closing,
side,
cur,
start,
end,
loading = false,
empty = false,
}: {
terms: Term[];
opening: number;
closing: number;
side: Side;
cur: Omit<Money, "amount">;
start: string;
end: string;
loading?: boolean;
empty?: boolean;
} = $props();
const TONE = {
expense: "text-expense",
income: "text-income",
neutral: "text-cladd-fg",
transfer: "text-transfer",
};
function word(value: number, asset: string) {
if (side === "asset") {
return asset;
}
return (side === "credit") === value >= 0 ? "Owed to you" : "You owe";
}
const shown = (value: number) => (side === "asset" ? value : Math.abs(value));
const change = $derived(closing - opening);
const changeTone = $derived.by(() => {
if (side !== "asset" || Math.abs(change) < 0.005) {
return "text-cladd-fg-soft";
}
return change > 0 ? "text-income" : "text-expense";
});
const items = $derived([
{
key: "open",
label: `${word(opening, "Opening")}, ${formatDate(addDays(start, -1), "short")}`,
op: "",
tone: "text-cladd-fg",
value: shown(opening),
},
...terms.map((t) => ({
key: t.flow,
label: t.label,
op: t.up ? "+" : "−",
tone: Math.abs(t.value) < 0.005 ? "text-cladd-fg-soft" : TONE[t.tone],
value: Math.abs(t.value),
})),
{
key: "close",
label: `${word(closing, "Closing")}, ${formatDate(end, "short")}`,
op: "=",
tone: "text-cladd-fg",
value: shown(closing),
},
]);
const money = (amount: number, signed = false) =>
empty ? " " : display.text([{ ...cur, amount }], signed);
const original = (amount: number, signed = false) =>
display.total([{ ...cur, amount }])?.converted
? formatMoney({ ...cur, amount }, signed)
: undefined;
</script>
<section
aria-busy={loading}
aria-label="Balance change"
class="cladd-surface cladd-outline rounded-cladd-lg px-3 py-3 transition-opacity lg:px-4 {loading
? "opacity-60"
: ""}"
>
<div
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-baseline gap-x-2 gap-y-1 lg:flex lg:flex-wrap lg:items-end lg:gap-x-4 lg:gap-y-3"
>
{#each items as item (item.key)}
<div
class="col-span-3 grid grid-cols-subgrid items-baseline lg:grid-cols-[auto_auto] lg:gap-x-2 {item.key ===
"close"
? "border-cladd-outline max-lg:mt-1 max-lg:border-t max-lg:pt-1"
: ""}"
>
<span
aria-hidden="true"
class="text-center text-[18px] text-cladd-fg-softer leading-tight lg:col-start-1 lg:row-start-2 lg:text-[22px] {item.op
? ""
: "lg:hidden"}"
>{item.op}</span
>
<span
class="truncate text-[13px] text-cladd-fg-soft lg:col-start-2 lg:row-start-1 lg:text-[12px]"
>{item.label}</span
>
<span
class="num text-right font-semibold text-[18px] leading-tight tracking-[-0.02em] lg:col-start-2 lg:row-start-2 lg:text-left lg:text-[22px] {item.tone}"
title={original(item.value)}
>{money(item.value)}</span
>
</div>
{/each}
<div
class="col-span-3 grid grid-cols-subgrid items-baseline lg:ml-auto lg:flex lg:flex-col lg:items-end"
>
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
>Change</span
>
<span
class="num text-right font-semibold text-[15px] {changeTone}"
title={original(change, true)}
>{money(change, true)}</span
>
</div>
</div>
</section>
@@ -0,0 +1,106 @@
<script lang="ts">
import {
type Side,
type Sums,
terms,
viewOf,
} from "#lib/accounts/statement.js";
import Columns from "#lib/components/charts/Columns.svelte";
import {
type LegendItem,
type StackRow,
tones,
wash,
} from "#lib/components/charts/chart.js";
import Legend from "#lib/components/charts/Legend.svelte";
import { currentLocale, type Money } from "#lib/format.js";
const NARROW = 560;
const COLOR = {
expense: tones.expense,
income: tones.income,
neutral: "var(--cladd-fg-softer)",
transfer: wash(tones.transfer, 55),
};
let {
months,
side,
cur,
moved,
selected,
partial,
onselect,
}: {
months: { month: string; total: Sums }[];
side: Side;
cur: Omit<Money, "amount">;
moved: boolean;
selected: string | null;
partial: string | null;
onselect: (month: string) => void;
} = $props();
let hover = $state<string | null>(null);
let width = $state(0);
const name = (month: string, style: "narrow" | "short" | "long") =>
new Date(`${month.slice(0, 7)}-15T12:00:00`).toLocaleDateString(
currentLocale(),
style === "long" ? { month: "long", year: "numeric" } : { month: style }
);
const view = $derived(viewOf(cur));
const format = (value: number) => view.format(value, true);
const parts = $derived(
months.map((m) => ({
month: m.month,
terms: terms(side, m.total).slice(0, 4),
}))
);
const keys = $derived(
(parts[0]?.terms ?? [])
.filter((t) => moved || t.tone !== "transfer")
.map((t) => ({ color: COLOR[t.tone], key: t.flow, label: t.label }))
);
const rows = $derived<StackRow[]>(
parts.map((p) => ({
key: p.month,
label: name(p.month, width < NARROW ? "narrow" : "short"),
values: Object.fromEntries(
p.terms.map((t) => [t.flow, t.value * view.rate])
),
}))
);
const read = $derived(hover ?? selected ?? rows.at(-1)?.key ?? null);
const row = $derived(rows.find((r) => r.key === read));
const legend = $derived<LegendItem[]>(
keys.map((k) => ({
...k,
shape: "rect",
value: format(row?.values[k.key] ?? 0),
}))
);
</script>
<div class="flex min-w-0 flex-col gap-2" bind:clientWidth={width}>
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="w-[8.5rem] shrink-0 text-[12px] text-cladd-fg"
>{read ? name(read, "long") : ""}</span
>
<Legend class="min-w-0 flex-1" items={legend} />
</div>
<Columns
axisFormat={view.axis}
{format}
height={208}
{keys}
label="Money in and out by month"
legend={false}
onselect={(r) => onselect(r.key)}
{partial}
{rows}
{selected}
bind:active={hover}
/>
</div>
+2 -2
View File
@@ -31,8 +31,8 @@ export const LIABILITY_LABEL: Record<string, string> = {
};
export const DIRECTION_LABEL: Record<string, string> = {
credit: "Owed to me",
debit: "I owe",
credit: "Owed to you",
debit: "You owe",
};
export const PERIOD_LABEL: Record<string, string> = {
+19 -7
View File
@@ -4,7 +4,7 @@
import { invalidate } from "#lib/api/lists.js";
import type { Range } from "#lib/dates.js";
import { formatMoney } from "#lib/format.js";
import { type Line, parseAmount, saveLimit } from "./data.js";
import { type Line, lineMoney, parseAmount, saveLimit } from "./data.js";
export type Move = 0 | 1 | -1;
@@ -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
@@ -133,11 +149,7 @@
type="button"
>
{line.amount
? formatMoney({
amount: line.amount,
decimals: line.cur.decimals,
symbol: line.cur.symbol,
})
? lineMoney(line, line.amount)
: `${line.cur.symbol} set amount`}
</button>
{/if}
+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>
+85 -284
View File
@@ -1,41 +1,28 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Budget,
BudgetLimit,
One,
TransactionGroup,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import type { Budget, BudgetLimit, One } from "#lib/api/types.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
import {
breakdown,
curOfSplit,
dayOf,
firstDay,
fromEntries,
inRange,
periodRanges,
rows as splitRows,
stepLabel,
} from "#lib/components/categories/sums.js";
import { tones, wash } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import type { Lane, StackRow } from "#lib/components/charts/chart.js";
import Dimension from "#lib/components/dimension/Dimension.svelte";
import LimitLine from "#lib/components/dimension/LimitLine.svelte";
import LimitStrip from "#lib/components/dimension/LimitStrip.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { addDays, contains, today } from "#lib/dates.js";
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { budgetSpec } from "#lib/dimension/spec.js";
import {
limitFacts,
limitsIn,
stripsOf,
withLimits,
} from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { longMonth } from "#lib/reports/board.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { limitRange } from "./data.js";
let { id, limitId }: { id: string; limitId?: string } = $props();
@@ -48,166 +35,43 @@
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
queryKey: ["budget-limits", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
queryKey: ["budget-transactions", id],
}));
const model = new DimensionModel(() => ({ spec: budgetSpec(id) }));
const name = $derived(budget.data?.data.attributes.name ?? "");
const sortedLimits = $derived(
[...(limits.data ?? [])].sort((a, b) =>
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
const list = $derived(limits.data ?? []);
const limit = $derived(list.find((l) => l.id === limitId));
const shown = $derived(limitsIn(list, model.into));
const facts = $derived(
model.range
? limitFacts(shown, model.main, model.range, model.now).sort(
(a, b) =>
Number(b.code === limit?.attributes.currency_code) -
Number(a.code === limit?.attributes.currency_code)
)
: []
);
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
const range = $derived(limit ? limitRange(limit) : null);
const strips = $derived(stripsOf(shown, model.main, model.months));
const allRows = $derived(
splitRows(groups.data ?? [], (s) => s.budget_id === id)
);
const scoped = $derived(
limit && range
? inRange(allRows, range).filter(
(r) => r.split.currency_code === limit.attributes.currency_code
)
: allRows
);
const spentRows = $derived(
scoped.filter((r) => r.split.type === "withdrawal")
);
const codes = $derived(
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
Boolean
)
);
const code = $derived(
limit?.attributes.currency_code ??
(codes.includes(page.url.searchParams.get("currency") ?? "")
? page.url.searchParams.get("currency")
: codes[0]) ??
""
);
const inCur = $derived(
spentRows.filter((r) => r.split.currency_code === code)
);
const cur = $derived(
inCur[0]
? curOfSplit(inCur[0].split)
: {
code,
decimals: limit?.attributes.currency_decimal_places ?? 2,
symbol: limit?.attributes.currency_symbol ?? code,
$effect(() => {
if (limit) {
const range = limitRange(limit);
untrack(() => period.set(range));
}
);
const fmt = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
const visibleGroups = $derived.by(() => {
if (!range) {
return groups.data ?? [];
}
const ids = new Set(scoped.map((r) => r.group.id));
return (groups.data ?? []).filter((g) => ids.has(g.id));
});
const view = $derived(period.view);
const history = $derived.by(() => {
const first = firstDay(inCur) ?? period.start;
const end = period.end > first ? period.end : today();
const ranges = periodRanges(end, view, 400)
.filter((r) => r.end >= first)
.reverse();
return ranges.map((r) => {
const spent = inRange(inCur, r).reduce(
(sum, row) => sum + toNumber(row.split.amount),
0
$effect(() => {
const { start, end } = period;
untrack(() => {
const range = limit && limitRange(limit);
if (range && (range.start !== start || range.end !== end)) {
setTimeout(() =>
goto(`/budgets/show/${id}?start=${start}&end=${end}`, {
replace: true,
reset: false,
})
);
const own = sortedLimits.find(
(l) =>
l.attributes.currency_code === code &&
contains(r, l.attributes.start ?? "")
);
const amount = own ? toNumber(own.attributes.amount) : 0;
return {
format: fmt,
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
key: r.start,
label: stepLabel(r, view),
values: amount
? {
left: Math.max(0, amount - spent),
over: Math.max(0, spent - amount),
spent: Math.min(spent, amount),
}
: { left: 0, over: 0, spent },
};
});
});
const historyKeys = [
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
{ color: tones.expense, key: "over", label: "Overspent" },
];
const burn = $derived.by(() => {
if (!(limit && range)) {
return [];
}
const amount = toNumber(limit.attributes.amount);
const last = range.end < today() ? range.end : today();
const byDay = new Map<string, number>();
for (const row of inCur) {
byDay.set(
dayOf(row),
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
);
}
const points: { x: string; y: number }[] = [];
let left = amount;
for (let d = range.start; d <= last; d = addDays(d, 1)) {
left -= byDay.get(d) ?? 0;
points.push({ x: d, y: left });
}
return [
{
area: true,
format: fmt,
key: "left",
label: "Left",
points,
strong: true,
},
{
color: "var(--cladd-fg-softer)",
format: fmt,
key: "pace",
label: "Even pace",
points: [
{ x: range.start, y: amount },
{ x: range.end, y: 0 },
],
},
];
});
const byCategory = $derived(
breakdown(inCur, (s) => ({
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
label: s.category_name ?? "(no category)",
}))
);
const bySource = $derived(
breakdown(inCur, (s) => ({
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
label: s.source_name ?? "",
}))
);
const byDestination = $derived(
breakdown(inCur, (s) => ({
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
label: s.destination_name ?? "",
}))
);
async function remove() {
await api(`/v1/budgets/${id}`, { method: "DELETE" });
@@ -227,16 +91,14 @@
<PageHeader
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
subtitle={limit && range
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
: undefined}
subtitle={period.label}
title={name || "Budget"}
>
{#snippet actions()}
<Button
href="/budgets/edit/{id}"
size="sm"
title="Edit budget (E)"
title="Shift+E"
variant="outline"
>Edit</Button
>
@@ -244,111 +106,50 @@
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
{#if limit && range}
<Box
title="Chart for all transactions for budget &quot;{name}&quot; between {formatRange(
range.start,
range.end
)} in {cur.code}"
{#snippet header(
lane: Lane,
row: StackRow | undefined
)}
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
{#if row}
<span class="text-cladd-fg-soft">{longMonth(row.key)}</span>
<span class="num font-semibold text-cladd-fg"
>{lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))}</span
>
{#if burn[0]?.points.length}
<LineChart
label="Amount left in this budget"
marker={contains(range, today()) ? today() : undefined}
series={burn}
zero
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
{/if}
</Box>
{:else}
<Box title="Chart of all transactions for budget &quot;{name}&quot;">
{#snippet tools()}
<CurrencyTabs {codes} current={code} />
{/snippet}
{#if history.length > 0}
<StackedBars
format={fmt}
keys={historyKeys}
label="Spent per period against the budgeted amount"
rows={history}
/>
{:else}
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
{/if}
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-3">
<BreakdownBox items={byCategory} title="Expenses by category" />
<BreakdownBox items={bySource} title="Expenses by asset account" />
<BreakdownBox
items={byDestination}
title="Expenses by destination account"
/>
</div>
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
<GroupsBox
groups={visibleGroups}
loading={groups.isPending}
title="Transactions"
{#if row.values.left !== undefined}
<span class="text-cladd-fg-soft"
>of
<span class="num"
>{lane.format((row.values.spent ?? 0) + row.values.left)}</span
>
{#snippet links()}
{#if limit}
<a class="text-brand hover:underline" href="/budgets/show/{id}"
>Show everything</a
limit</span
>
{/if}
{/snippet}
</GroupsBox>
<nav
aria-label="Budgeted amounts"
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
>
{#each sortedLimits as item (item.id)}
{@const r = limitRange(item)}
{@const amount = toNumber(item.attributes.amount)}
{@const spent = -(
fromEntries(item.attributes.spent).find(
(s) => s.code === item.attributes.currency_code
)?.amount ?? 0
)}
{@const money = (value: number) =>
formatMoney({
amount: value,
decimals: item.attributes.currency_decimal_places,
symbol: item.attributes.currency_symbol,
})}
<a
aria-current={item.id === limitId ? "page" : undefined}
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
limitId
? "bg-row-cursor outline-2 outline-brand"
: "hover:bg-row-hover"}"
href="/budgets/show/{id}/{item.id}"
>
<span class="font-semibold text-cladd-fg"
>{formatRange(r.start, r.end)}</span
>
<span class="flex justify-between gap-2">
<span class="num text-expense">{money(spent)}</span>
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
{/if}
{#if lane.typical}
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
<span
aria-hidden="true"
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
></span>
typical
<span class="num">{lane.format(lane.typical)}</span>
</span>
<Meter
height={6}
max={amount}
segments={spent > amount
? [
{ color: tones.warn, value: amount },
{ color: tones.expense, value: spent - amount },
]
: [{ color: "var(--ff-brand)", value: spent }]}
/>
</a>
{/each}
</nav>
</div>
</div>
{/if}
{/snippet}
<Dimension
{header}
{model}
noun="purchases"
shape={(lanes) => withLimits(lanes, shown)}
>
{#snippet extra()}
{#if !limits.isPending}
<LimitLine {facts} />
{/if}
{/snippet}
{#snippet below()}
<LimitStrip budgetId={id} cursor={model.focus} {strips} />
{/snippet}
</Dimension>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { percent } from "#lib/format.js";
import { mergeTops, type Top, topMoney } from "./data.js";
const CENT = 0.005;
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
const shown = $derived(mergeTops(tops));
const free = $derived(
shown.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[]
);
</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 shown 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.approx ? "≈" : ""}{top.cur.code}</span
>
{#if free}
{@render stat(
"Available",
top.available === null ? null : topMoney(top, top.available)
)}
{/if}
{@render stat(
"Budgeted",
top.budgeted ? topMoney(top, top.budgeted) : null
)}
{#if free}
{@const unallocated = (top.available ?? 0) - top.budgeted}
{@render stat(
"Left to budget",
top.available === null ? null : topMoney(top, unallocated),
unallocated < 0 ? "text-expense" : ""
)}
{/if}
{@const left = top.budgeted - top.inBudgets}
{@render stat(
"Left in budgets",
top.budgeted ? topMoney(top, left) : null,
left < 0 ? "text-expense" : ""
)}
{@render stat("Spent", topMoney(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]"
>{topMoney(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}
+674
View File
@@ -0,0 +1,674 @@
<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}
code={split.currency_code}
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}
+182 -21
View File
@@ -7,22 +7,33 @@ 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 { toNumber } from "#lib/format.js";
import {
contains,
daysBetween,
isDynamic,
type Range,
shiftPeriod,
today,
type ViewRange,
} from "#lib/dates.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
export interface Line {
amount: number | null;
approx?: boolean;
cur: Cur & { id: string };
key: string;
limit?: Item<BudgetLimit>;
@@ -43,6 +54,16 @@ export interface Pool {
spent: number;
}
export interface Top {
approx?: boolean;
available: number | null;
budgeted: number;
cur: Cur;
inBudgets: number;
outside: number;
spent: number;
}
export interface Pace {
expected: number;
fraction: number;
@@ -58,6 +79,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 +122,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 +236,102 @@ 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
);
}
function ratesOf(codes: string[]): number[] | null {
if (!display.active || codes.every((code) => code === display.code)) {
return null;
}
const rates = codes.map((code) => display.rate(code));
return rates.every((rate) => rate !== null) ? rates : null;
}
const scaled = (values: number[], rates: number[]) =>
values.reduce((total, value, i) => total + value * rates[i], 0);
export function mergeLines(lines: Line[], range: Range): Line[] {
const rates = ratesOf(lines.map((line) => line.cur.code));
if (!rates) {
return lines;
}
const limited = lines.find((line) => line.amount !== null);
return [
{
amount: limited
? scaled(
lines.map((line) => line.amount ?? 0),
rates
)
: null,
approx: true,
cur: { ...display.target, id: "" },
key: lines[0].key,
range: limited?.range ?? range,
spent: scaled(
lines.map((line) => line.spent),
rates
),
},
];
}
export function mergeTops(tops: Top[]): Top[] {
const rates = ratesOf(tops.map((top) => top.cur.code));
if (!rates) {
return tops;
}
const sum = (pick: (top: Top) => number) => scaled(tops.map(pick), rates);
return [
{
approx: true,
available: tops.some((top) => top.available !== null)
? sum((top) => top.available ?? 0)
: null,
budgeted: sum((top) => top.budgeted),
cur: display.target,
inBudgets: sum((top) => top.inBudgets),
outside: sum((top) => top.outside),
spent: sum((top) => top.spent),
},
];
}
export const lineMoney = (line: Line, amount: number) => {
const text = display.text([{ ...line.cur, amount }]);
return line.approx ? `≈${text}` : text;
};
export const topMoney = (top: Top, amount: number) =>
`${top.approx ? "≈" : ""}${formatMoney({ amount, decimals: 0, symbol: top.cur.symbol })}`;
export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) {
return null;
@@ -225,16 +342,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,
});
}
});
}
@@ -1,64 +0,0 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { formatMoney } from "#lib/format.js";
import type { Breakdown } from "./sums.js";
const TOP = 8;
let {
title,
items,
color = "var(--ff-expense)",
}: { title: string; items: Breakdown[]; color?: string } = $props();
function bars(item: Breakdown) {
const top = item.bars.slice(0, TOP);
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
const format = (value: number) =>
formatMoney({
amount: value,
decimals: item.cur.decimals,
symbol: item.cur.symbol,
});
const list = top.map((bar) => ({
format,
href: bar.href,
key: bar.label,
label: bar.label,
value: bar.amount,
}));
if (rest > 0) {
list.push({
format,
href: undefined,
key: "\u0000other",
label: `${item.bars.length - TOP} more`,
value: rest,
});
}
return list;
}
</script>
<Box {title}>
{#if items.length === 0}
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
{/if}
<div class="flex flex-col gap-3">
{#each items as item (item.cur.code)}
{#if items.length > 1}
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
{item.cur.code}
</h3>
{/if}
<BarChart
bars={bars(item)}
{color}
label={title}
labelWidth="minmax(5rem, 10rem)"
orientation="horizontal"
/>
{/each}
</div>
</Box>
@@ -1,28 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
let { codes, current }: { codes: string[]; current: string } = $props();
function href(code: string) {
const url = new URL(page.url.href);
url.searchParams.set("currency", code);
return url.pathname + url.search;
}
</script>
{#if codes.length > 1}
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
{#each codes as code (code)}
<a
aria-current={code === current ? "true" : undefined}
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
? "bg-row-selected font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:bg-row-hover"}"
data-sveltekit-noscroll
data-sveltekit-replacestate
href={href(code)}
>{code}</a
>
{/each}
</nav>
{/if}
@@ -1,5 +1,7 @@
<script lang="ts">
import Amount, { type Tone } from "#lib/components/Amount.svelte";
import Money from "#lib/components/Money.svelte";
import { display } from "#lib/money/display.svelte.js";
import type { Sum } from "./sums.js";
let {
@@ -8,6 +10,8 @@
empty = "",
class: className = "",
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
const merged = $derived(display.total(sums) !== null);
</script>
{#if sums.length === 0}
@@ -16,6 +20,9 @@
{/if}
{:else}
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
{#if merged}
<Money parts={sums} {tone} />
{:else}
{#each sums as sum (sum.code)}
<Amount
amount={sum.amount}
@@ -25,5 +32,6 @@
{tone}
/>
{/each}
{/if}
</span>
{/if}
+29 -125
View File
@@ -1,32 +1,21 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Category, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { invalidate } from "#lib/api/lists.js";
import type { Category, One } from "#lib/api/types.js";
import {
periodPath,
usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import Dimension from "#lib/components/dimension/Dimension.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { categorySpec } from "#lib/dimension/spec.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import CurrencyTabs from "./CurrencyTabs.svelte";
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
import PeriodList from "./PeriodList.svelte";
import {
firstDay,
periodRanges,
rows as splitRows,
stepLabel,
stepRanges,
} from "./sums.js";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
@@ -38,49 +27,12 @@
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
queryKey: ["category", id],
}));
const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
queryKey: ["category-transactions", id],
}));
const name = $derived(category.data?.data.attributes.name ?? "");
const own = $derived(
splitRows(groups.data ?? [], (s) => s.category_id === id)
);
const curs = $derived(currencies(own));
const cur = $derived(
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
curs[0]
);
const fmt = $derived((value: number) =>
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
);
const periodRows = $derived(
cur
? flowRows(own, stepRanges(period.range), cur, (r) =>
r.start === r.end
? String(Number(r.start.slice(8)))
: formatDate(r.start, "short")
)
: []
);
const historyRows = $derived.by(() => {
if (!cur) {
return [];
}
const first = firstDay(own) ?? period.start;
const ranges = periodRanges(period.end, period.view, 400)
.filter((r) => r.end >= first)
.reverse();
return flowRows(
own,
ranges,
cur,
(r) => stepLabel(r, period.view),
(r) => periodPath(base, r.start, r.end)
);
});
const model = new DimensionModel(() => ({
everything,
spec: categorySpec(id),
}));
const other = $derived(everything ? base : `${base}/all`);
async function remove() {
await api(`/v1/categories/${id}`, { method: "DELETE" });
@@ -95,6 +47,12 @@
label: "Edit category",
run: () => goto(`/categories/edit/${id}`),
},
{
group: "Category",
keys: "A",
label: everything ? "Show the current period" : "Show all time",
run: () => goto(other),
},
]);
</script>
@@ -103,16 +61,17 @@
{ href: "/categories", label: "Categories" },
{ label: name || "Category" },
]}
subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
subtitle={everything ? "All time" : period.label}
title={name || "Category"}
>
{#snippet actions()}
<Button href={other} size="sm" title="Shift+A" variant="outline"
>{everything ? "This period" : "All time"}</Button
>
<Button
href="/categories/edit/{id}"
size="sm"
title="Edit category (E)"
title="Shift+E"
variant="outline"
>Edit</Button
>
@@ -120,66 +79,11 @@
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4">
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
{#if !everything}
<Box
title="Chart for all transactions for category &quot;{name}&quot; between {formatDate(
period.start
)} and {formatDate(period.end)}"
>
{#snippet tools()}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned in this period"
rows={periodRows}
/>
</Box>
{/if}
<Box title="Chart for all transactions for category &quot;{name}&quot;">
{#snippet tools()}
{#if everything}
<CurrencyTabs
codes={curs.map((c) => c.code)}
current={cur?.code ?? ""}
/>
{/if}
{/snippet}
<StackedBars
format={fmt}
height={220}
keys={FLOW_KEYS}
label="Spent and earned per period"
rows={historyRows}
/>
</Box>
</div>
<PeriodList
groups={groups.data ?? []}
href={(r) => periodPath(base, r.start, r.end)}
keep={(s) => s.category_id === id}
loading={groups.isPending}
sidebar={!everything}
title="Transactions"
>
{#snippet links()}
{#if everything}
<a class="text-brand hover:underline" href={base}
>Show the current period and overview</a
>
{:else}
<a class="text-brand hover:underline" href="{base}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</PeriodList>
</div>
<Dimension
{model}
noun={model.dir === -1 ? "purchases" : "payments"}
onmonth={(range) =>
everything
? goto(periodPath(base, range.start, range.end))
: period.set(range)}
/>
-56
View File
@@ -1,56 +0,0 @@
import {
type StackKey,
type StackRow,
tones,
} from "#lib/components/charts/chart.js";
import type { Range } from "#lib/dates.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
export const FLOW_KEYS: StackKey[] = [
{ color: tones.income, key: "earned", label: "Earned" },
{ color: tones.expense, key: "spent", label: "Spent" },
];
export function currencies(items: Row[]): Cur[] {
const found = new Map<string, Cur>();
const counts = new Map<string, number>();
for (const { split } of items) {
const cur = curOfSplit(split);
found.set(cur.code, cur);
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
}
return [...found.values()].sort(
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
);
}
export function flowRows(
items: Row[],
ranges: Range[],
cur: Cur,
label: (range: Range) => string,
href?: (range: Range) => string
): StackRow[] {
const own = items.filter((row) => row.split.currency_code === cur.code);
const format = (value: number) =>
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
return ranges.map((range) => {
let spent = 0;
let earned = 0;
for (const { split } of inRange(own, range)) {
if (split.type === "withdrawal") {
spent -= toNumber(split.amount);
} else if (split.type === "deposit") {
earned += toNumber(split.amount);
}
}
return {
format,
href: href?.(range),
key: range.start,
label: label(range),
values: { earned, spent },
};
});
}
+151 -20
View File
@@ -11,6 +11,7 @@
type StackRow,
stack,
textWidth,
tones,
} from "./chart.js";
import Legend from "./Legend.svelte";
@@ -24,6 +25,9 @@
const TILTED_CHARS = 16;
const TILTED_CHAR_WIDTH = 4.9;
const LABEL_GAP = 13;
const BARE_BOTTOM = 4;
const DOT_ROOM = 1.5;
const DOT_R = 3.5;
let {
keys,
@@ -33,6 +37,16 @@
axisFormat = (value: number) => compactMoney(value),
label = "Bar chart",
active = $bindable(null),
selected = null,
reference = null,
partial = null,
dots,
dotColor = tones.income,
gutter,
legend: showLegend = true,
axis = true,
tilt = true,
keyboard = true,
onselect,
}: {
keys: StackKey[];
@@ -42,30 +56,63 @@
axisFormat?: (value: number) => string;
label?: string;
active?: string | null;
onselect?: (row: StackRow) => void;
selected?: string | null;
reference?: number | null;
partial?: string | null;
dots?: Record<string, number>;
dotColor?: string;
gutter?: number;
legend?: boolean;
axis?: boolean;
tilt?: boolean;
keyboard?: boolean;
onselect?: (row: StackRow, segment?: string) => void;
} = $props();
const uid = $props.id();
let width = $state(0);
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
const stackTop = $derived(Math.max(0, ...stacks.map((s) => s.positive)));
const dotValues = $derived(
Object.values(dots ?? {}).filter(
(v) => v > 0 && (stackTop === 0 || v <= stackTop * DOT_ROOM)
)
);
const yBase = $derived(
scaleLinear()
.domain([
Math.min(0, ...stacks.map((s) => s.negative)),
Math.max(0, ...stacks.map((s) => s.positive)) || 1,
Math.max(stackTop, reference ?? 0, ...dotValues) || 1,
])
.nice(yCount)
);
const yTicks = $derived(yBase.ticks(yCount));
const left = $derived(
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
gutter ??
Math.ceil(
Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10
)
);
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
const tilted = $derived(
const crowded = $derived(
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
);
const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
const tilted = $derived(tilt && crowded);
const flatEvery = $derived(
crowded && !tilt
? Math.ceil(
(Math.max(...rows.map((r) => textWidth(r.label, 6.2))) + 6) / step
)
: 1
);
const bottom = $derived.by(() => {
if (!axis) {
return BARE_BOTTOM;
}
return tilted ? TILTED_BOTTOM : FLAT_BOTTOM;
});
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
const y = $derived(yBase.copy().range([height - bottom, TOP]));
const x = $derived(
@@ -88,7 +135,13 @@
const activeIndex = $derived(
active ? rows.findIndex((r) => r.key === active) : -1
);
const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
const selectedIndex = $derived(
selected ? rows.findIndex((r) => r.key === selected) : -1
);
const readRow = $derived(
rows[Math.max(0, activeIndex >= 0 ? activeIndex : selectedIndex)]
);
const yTop = $derived(y.domain()[1]);
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
const legend = $derived<LegendItem[]>(
readRow
@@ -113,17 +166,31 @@
: text;
}
function select(row: StackRow | undefined) {
function select(row: StackRow | undefined, segment?: string) {
if (!row) {
return;
}
if (onselect) {
onselect(row);
onselect(row, segment);
} else if (row.href) {
goto(row.href);
}
}
function segmentAt(event: MouseEvent, index: number) {
const found = stacks[index];
if (!found) {
return;
}
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
const value = y.invert(event.clientY - box.top);
return found.segments.find(
(seg) =>
value >= Math.min(seg.from, seg.to) &&
value <= Math.max(seg.from, seg.to)
)?.key;
}
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
function pick(event: PointerEvent) {
@@ -136,6 +203,9 @@
}
function onkeydown(event: KeyboardEvent) {
if (!keyboard) {
return;
}
if (event.key === "Enter") {
select(rows[activeIndex]);
return;
@@ -179,6 +249,7 @@
</script>
<div class="flex min-w-0 flex-col gap-2">
{#if showLegend}
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
{#if keys.length > 1 && readRow}
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
@@ -187,6 +258,7 @@
{/if}
<Legend class="min-w-0 flex-1" items={legend} />
</div>
{/if}
<div
aria-label={label}
aria-valuemax={Math.max(0, rows.length - 1)}
@@ -194,19 +266,45 @@
aria-valuenow={Math.max(0, activeIndex)}
aria-valuetext={valueText}
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
onblur={() => (active = null)}
onclick={() => select(rows[activeIndex])}
onfocus={() => (active ??= rows[0]?.key ?? null)}
onblur={() => keyboard && (active = null)}
onclick={(event) =>
select(rows[activeIndex], segmentAt(event, activeIndex))}
onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)}
{onkeydown}
onpointerleave={() => (active = null)}
onpointermove={pick}
role="slider"
tabindex="0"
tabindex={keyboard ? 0 : -1}
style:height="{height}px"
bind:clientWidth={width}
>
{#if width > 0 && rows.length > 0}
<svg aria-hidden="true" class="block" {height} {width}>
{#if partial}
<defs>
<pattern
height="5"
id="{uid}-hatch"
patternTransform="rotate(45)"
patternUnits="userSpaceOnUse"
width="5"
>
<rect fill="var(--cladd-surface)" height="5" width="2" />
</pattern>
</defs>
{/if}
{#if selectedIndex >= 0}
<rect
fill="var(--ff-row-cursor)"
fill-opacity="0.6"
height={height - bottom - TOP + 6}
rx="3"
width={x.step()}
x={(x(rows[selectedIndex].key) ?? 0) -
(x.step() - x.bandwidth()) / 2}
y={TOP - 6}
/>
{/if}
{#each yTicks as tick (tick)}
<line
stroke="var(--cladd-outline)"
@@ -239,16 +337,17 @@
{@const dim = activeIndex >= 0 && activeIndex !== i}
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
{#each s.segments as seg, j (seg.key)}
<path
d={segmentPath(
{@const d = segmentPath(
cx,
seg.from,
seg.to,
seg.outer,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)}
style:fill={seg.color}
/>
<path {d} style:fill={seg.color} />
{#if partial === s.row.key}
<path {d} fill="url(#{uid}-hatch)" />
{/if}
{/each}
</g>
{#if capLabels && s.segments[0]}
@@ -268,7 +367,7 @@
</text>
{/if}
{/if}
{#if tilted && i % labelEvery === 0}
{#if axis && tilted && i % labelEvery === 0}
<text
fill={activeIndex === i
? "var(--cladd-fg)"
@@ -282,20 +381,52 @@
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
)}
</text>
{:else if !tilted}
{:else if axis && !tilted && i % flatEvery === 0}
<text
fill={activeIndex === i
fill={activeIndex === i || selectedIndex === i
? "var(--cladd-fg)"
: "var(--cladd-fg-soft)"}
font-size="11"
font-weight={selectedIndex === i ? 600 : 400}
text-anchor="middle"
x={cx}
y={height - 6}
>
{short(s.row.label, Math.floor(x.step() / 6.4))}
{flatEvery > 1
? s.row.label
: short(s.row.label, Math.floor(x.step() / 6.4))}
</text>
{/if}
{/each}
{#if reference}
<line
stroke="var(--cladd-fg-soft)"
stroke-dasharray="2 3"
stroke-width="1.25"
x1={left}
x2={width - RIGHT}
y1={crisp(y(reference))}
y2={crisp(y(reference))}
/>
{/if}
{#each rows as row (row.key)}
{@const value = dots?.[row.key] ?? 0}
{#if value > 0}
{@const cx = (x(row.key) ?? 0) + x.bandwidth() / 2}
{#if value > yTop}
<path d="M{cx - 4},{TOP - 1}l4,-6l4,6Z" style:fill={dotColor} />
{:else}
<circle
{cx}
cy={y(value)}
r={DOT_R}
stroke="var(--cladd-surface)"
stroke-width="1.5"
style:fill={dotColor}
/>
{/if}
{/if}
{/each}
</svg>
{/if}
</div>
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
import { scaleLinear } from "d3-scale";
import type { Snippet } from "svelte";
import { compactMoney } from "#lib/format.js";
import { versus, versusText } from "#lib/reports/typical.js";
import Columns from "./Columns.svelte";
import {
type Lane,
type StackKey,
type StackRow,
stack,
textWidth,
tones,
} from "./chart.js";
import Legend from "./Legend.svelte";
const LANE_HEIGHT = 112;
const AXIS_HEIGHT = 18;
const TICK_GAP = 44;
let {
lanes,
cursor = null,
hover = $bindable(null),
partial = null,
pinned = false,
dotLabel = "in",
dotColor = tones.income,
title = (row: StackRow) => row.label,
label = "Months by currency",
legend = true,
moreIsGood = false,
header,
onselect,
}: {
lanes: Lane[];
cursor?: string | null;
hover?: string | null;
partial?: string | null;
pinned?: boolean;
dotLabel?: string;
moreIsGood?: boolean;
dotColor?: string;
title?: (row: StackRow) => string;
label?: string;
legend?: boolean;
header?: Snippet<[Lane, StackRow | undefined]>;
onselect?: (month: string, lane: Lane, segment?: string) => void;
} = $props();
const read = $derived(pinned ? cursor : (hover ?? cursor));
const sharedKeys = $derived.by(() => {
const seen = new Map<string, StackKey>();
for (const lane of lanes) {
for (const key of lane.keys) {
if (!seen.has(key.key)) {
seen.set(key.key, key);
}
}
}
return [...seen.values()];
});
function widest(lane: Lane) {
const stacks = lane.rows.map((row) => stack(row, lane.keys));
const top = Math.max(
lane.typical ?? 0,
...stacks.map((s) => s.positive),
...Object.values(lane.dots ?? {})
);
const bottom = Math.min(0, ...stacks.map((s) => s.negative));
const height = lane.height ?? LANE_HEIGHT;
const count = Math.max(2, Math.floor((height - 60) / TICK_GAP));
const format = lane.axisFormat ?? ((v: number) => compactMoney(v));
const ticks = scaleLinear()
.domain([bottom, top || 1])
.nice(count)
.ticks(count);
return Math.max(...ticks.map((t) => textWidth(format(t))));
}
const gutter = $derived(
Math.ceil(Math.max(24, ...lanes.map((lane) => widest(lane)))) + 10
);
function rowOf(lane: Lane) {
return lane.rows.find((row) => row.key === read);
}
function totalOf(row: StackRow | undefined) {
return row ? Object.values(row.values).reduce((a, b) => a + b, 0) : 0;
}
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 === moreIsGood ? "text-income" : "text-expense";
}
</script>
{#snippet defaultHeader(
lane: Lane,
row: StackRow | undefined
)}
{@const value = totalOf(row)}
{@const sofar = row?.key === partial}
{@const ratio =
row && lane.typical && !sofar ? versus(value, lane.typical) : null}
{@const dot = row ? lane.dots?.[row.key] : undefined}
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
{#if row}
<span class="text-cladd-fg-soft">{title(row)}</span>
<span class="num font-semibold text-cladd-fg">{lane.format(value)}</span>
{#if sofar}
<span class="text-cladd-fg-soft">so far</span>
{:else if ratio !== null}
<span class={tone(ratio)}>{versusText(ratio)}</span>
{/if}
{/if}
{#if lane.typical}
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
<span
aria-hidden="true"
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
></span>
typical
<span class="num">{lane.format(lane.typical)}</span>
</span>
{/if}
{#if dot}
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
<span
aria-hidden="true"
class="size-2 rounded-full"
style:background={dotColor}
></span>
{dotLabel}
<span class="num text-cladd-fg">{lane.format(dot)}</span>
</span>
{/if}
{/snippet}
<figure aria-label={label} class="m-0 flex min-w-0 flex-col">
{#if legend && sharedKeys.length > 1}
<Legend
class="mb-2"
items={sharedKeys.map((k) => ({ ...k, shape: "rect" as const }))}
/>
{/if}
{#each lanes as lane, i (lane.key)}
{@const last = i === lanes.length - 1}
<div class="min-w-0 {i > 0 ? "border-cladd-outline border-t pt-1.5" : ""}">
<div
class="flex min-h-6 flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[12px]"
>
{@render (header ?? defaultHeader)(lane, rowOf(lane))}
</div>
<Columns
axis={last}
axisFormat={lane.axisFormat}
{dotColor}
dots={lane.dots}
format={lane.format}
{gutter}
height={(lane.height ?? LANE_HEIGHT) + (last ? AXIS_HEIGHT : 0)}
keyboard={false}
keys={lane.keys}
label="{label}: {lane.label}"
legend={false}
onselect={onselect
? (row, segment) => onselect(row.key, lane, segment)
: undefined}
{partial}
reference={lane.typical}
rows={lane.rows}
selected={cursor}
tilt={false}
bind:active={hover}
/>
</div>
{/each}
</figure>
+12
View File
@@ -39,6 +39,18 @@ export interface StackRow {
values: Record<string, number>;
}
export interface Lane {
axisFormat?: (value: number) => string;
dots?: Record<string, number>;
format: (value: number) => string;
height?: number;
key: string;
keys: StackKey[];
label: string;
rows: StackRow[];
typical?: number | null;
}
export interface MeterSegment {
color?: string;
label?: string;
@@ -1,188 +0,0 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui";
import { api } from "#lib/api/client.js";
import type {
Account,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import {
canReconcile,
createUrl,
editUrl,
go,
openOld,
reconcileUrl,
showUrl,
} from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { span } from "#lib/components/dashboard/data.js";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { contains } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
groupTitle,
signedAmount,
signedForeign,
} from "#lib/transactions/split.js";
const LIMIT = 10;
const TRIGGER =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2.5 font-semibold text-[12px]";
let {
account,
trend = [],
marker = -1,
}: { account: Item<Account>; trend?: number[]; marker?: number } = $props();
const a = $derived(account.attributes);
const cur = $derived({
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
const list = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${account.id}/transactions`, {
query: { ...span(period.start, period.end), limit: LIMIT },
}),
queryKey: ["account-tx", account.id, period.start, period.end, LIMIT],
}));
const rows = $derived(
(list.data?.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter(
(s) =>
(s.source_id === account.id || s.destination_id === account.id) &&
contains(period.range, s.date)
)
.map((split, i) => ({
foreign: signedForeign(split, account.id),
group,
key: `${group.id}-${i}`,
money: signedAmount(split, account.id),
split,
title:
group.attributes.transactions.length > 1
? `${groupTitle(group)}: ${split.description}`
: split.description,
}))
)
.slice(0, LIMIT)
);
const change = $derived(toNumber(a.balance_difference));
const trendColor = $derived(
change < 0 ? "var(--ff-expense)" : "var(--ff-income)"
);
</script>
<Box body="py-1" href={showUrl(account.id)} title={a.name}>
{#snippet tools()}
{#if trend.length > 1}
<Sparkline color={trendColor} {marker} values={trend} width={72} />
{/if}
{#if change !== 0}
<Amount amount={change} signed {...cur} class="text-[12px]" />
{/if}
{/snippet}
<div class="transition-opacity {list.isPlaceholderData ? "opacity-60" : ""}">
{#if list.isPending}
<div class="h-14"></div>
{:else if list.isError}
<p class="px-3 py-2 text-expense">{list.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-3 text-cladd-fg-soft">
No transactions in this period.
</p>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.key)}
<li>
<a
class="flex h-10 items-center gap-2 px-3 text-[12.5px] hover:bg-row-hover sm:h-7"
href="/transactions/show/{row.group.id}"
>
<span class="num w-12 shrink-0 text-cladd-fg-soft"
>{formatDate(row.split.date.slice(0, 10), "short")}</span
>
<span
class="min-w-0 flex-1 truncate text-cladd-fg"
title={row.title}
>{row.title}</span
>
{#if row.foreign}
<span class="num shrink-0 text-[11px] text-cladd-fg-soft"
>({formatMoney(row.foreign)})</span
>
{/if}
<Amount
amount={row.money.amount}
code={row.money.code}
decimals={row.money.decimals}
symbol={row.money.symbol}
tone={row.money.tone}
/>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{#snippet footer()}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="{TRIGGER} cladd-color-green text-cladd-primary"
>
Create <ChevronDown class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem
onSelect={() => go(createUrl("withdrawal", account.id))}
>New withdrawal</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("deposit", account.id))}
>New deposit</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("transfer", account.id))}
>New transfer</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="{TRIGGER} ml-auto text-cladd-fg">
<span class="num"
>{formatMoney({ amount: a.current_balance, ...cur })}</span
>
<ChevronDown class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem onSelect={() => go(showUrl(account.id))}
>Show</DropdownMenuItem
>
{#if canReconcile(account)}
<DropdownMenuItem onSelect={() => go(reconcileUrl(account.id))}
>Reconcile</DropdownMenuItem
>
{/if}
<DropdownMenuItem onSelect={() => go(editUrl(account.id))}
>Edit</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => openOld(showUrl(account.id))}
>Open in old UI</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu.Root>
{/snippet}
</Box>
@@ -0,0 +1,407 @@
<script lang="ts">
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import {
createQueries,
createQuery,
keepPreviousData,
} from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type {
Account,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type Row, useColumn } from "#lib/dashboard/column.svelte.js";
import { addDays, today } from "#lib/dates.js";
import {
formatDate,
formatMoney,
type Money,
toNumber,
} from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import {
groupTitle,
signedAmount,
editUrl as txEditUrl,
showUrl as txShowUrl,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
import {
accountsChartQuery,
accountsQuery,
byCurrency,
seriesPoints,
} from "./data.js";
const RECENT = 5;
const TREND_DAYS = 30;
const HISTORY_DAYS = 365;
const now = today();
let expanded = $state<string[]>([]);
const assets = createQuery(() => ({
...accountsQuery("asset", period.start, period.end),
placeholderData: keepPreviousData,
}));
const chart = createQuery(() =>
accountsChartQuery(addDays(now, -TREND_DAYS), now)
);
const recent = createQueries(() => ({
queries: expanded.map((id) => ({
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
query: {
end: now,
limit: RECENT,
start: addDays(now, -HISTORY_DAYS),
},
}),
queryKey: ["account-recent", id, now],
})),
}));
const shown = $derived.by(() => {
const list = (assets.data ?? [])
.filter((a) => a.attributes.active !== false)
.sort((a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0));
if (!("frontpageAccounts" in session.prefs)) {
return list;
}
const ids = new Set(session.frontpageAccounts);
return list.filter((a) => ids.has(a.id));
});
const trends = $derived(
new Map(
(chart.data ?? []).map((set) => [
set.label,
seriesPoints(set, false).map((p) => p.y),
])
)
);
function sumSeries(series: number[][]): number[] {
const length = Math.max(0, ...series.map((s) => s.length));
return Array.from({ length }, (_, i) =>
series.reduce((sum, s) => sum + (s[i] ?? 0), 0)
);
}
const moneyOf = (a: Account, amount: Money["amount"]): Money => ({
amount,
code: a.currency_code,
decimals: a.currency_decimal_places ?? 2,
symbol: a.currency_symbol ?? a.currency_code,
});
const balances = $derived(
shown.map((a) => moneyOf(a.attributes, a.attributes.current_balance))
);
const changes = $derived(
shown.map((a) => moneyOf(a.attributes, a.attributes.balance_difference))
);
const total = $derived(display.total(balances));
const change = $derived(display.total(changes));
const sumOf = (
accounts: Item<Account>[],
pick: (a: Account) => string | null | undefined
) => accounts.reduce((s, a) => s + toNumber(pick(a.attributes)), 0);
const converted = (a: Item<Account>) =>
display.convert(
toNumber(a.attributes.current_balance),
a.attributes.currency_code
) ?? 0;
const groups = $derived(
total
? [
{
accounts: shown.toSorted((a, b) => converted(b) - converted(a)),
balance: null,
change: null,
code: total.code,
trend: [],
},
]
: byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
([code, accounts]) => ({
accounts,
balance: moneyOf(
accounts[0].attributes,
sumOf(accounts, (a) => a.current_balance)
),
change: moneyOf(
accounts[0].attributes,
sumOf(accounts, (a) => a.balance_difference)
),
code,
trend: sumSeries(
accounts.map((a) => trends.get(a.attributes.name) ?? [])
),
})
)
);
const recentRows = $derived(
new Map(
expanded.map((id, i) => [
id,
(recent[i]?.data?.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter((s) => s.source_id === id || s.destination_id === id)
.map((split, n) => ({
date: split.date.slice(0, 10),
group,
key: `acc-${id}-tx-${group.id}-${n}`,
money: signedAmount(split, id),
title:
group.attributes.transactions.length > 1
? `${groupTitle(group)}: ${split.description}`
: split.description,
}))
)
.slice(0, RECENT),
])
)
);
const accountKey = (id: string) => `acc-${id}`;
function expand(id: string) {
if (expanded.includes(id)) {
const first = recentRows.get(id)?.[0];
if (first) {
column.point(first.key);
}
return;
}
expanded = [...expanded, id];
}
function fold(id: string) {
expanded = expanded.filter((e) => e !== id);
column.point(accountKey(id));
}
function toggle(id: string) {
if (expanded.includes(id)) {
fold(id);
} else {
expand(id);
}
}
const column = useColumn(1, () =>
groups.flatMap((g) =>
g.accounts.flatMap((account): Row[] => [
{
edit: () => goto(editUrl(account.id)),
expand: () => expand(account.id),
fold: () => fold(account.id),
key: accountKey(account.id),
open: () => goto(showUrl(account.id)),
},
...(recentRows.get(account.id) ?? []).map((tx) => ({
edit: () => goto(txEditUrl(tx.group.id)),
fold: () => fold(account.id),
key: tx.key,
open: () => goto(txShowUrl(tx.group.id)),
})),
])
)
);
const trendColor = (values: number[]) =>
(values.at(-1) ?? 0) < (values[0] ?? 0)
? "var(--ff-expense)"
: "var(--ff-income)";
</script>
{#snippet spark(
values: number[]
)}
{#if values.length > 1}
<Sparkline color={trendColor(values)} height={18} {values} width={56} />
{/if}
{/snippet}
{#snippet balance(
money: Money
)}
<span
class="num font-medium {toNumber(money.amount) < 0
? "text-expense"
: "text-cladd-fg"}"
title={display.active && money.code !== display.code
? formatMoney(money)
: undefined}
>{display.text([money])}</span
>
{/snippet}
<Box body="p-0" href="/accounts/asset" title="Accounts">
{#snippet tools()}
{#if total}
<span class="num font-semibold text-[13px] text-cladd-fg"
>{display.text(balances)}</span
>
{#if change?.amount}
<span
class="num text-[12px] {change.amount < 0
? "text-expense"
: "text-income"}"
>{display.text(changes, true)}</span
>
{/if}
{/if}
{/snippet}
{#if shown.length === 0}
<p class="px-3 py-3 text-cladd-fg-soft">
{assets.isPending ? "" : "No accounts on the home screen."}
</p>
{:else}
<table
class="w-full border-collapse text-[13px] transition-opacity {assets.isPlaceholderData
? "opacity-60"
: ""}"
>
<thead>
<tr class="h-7 text-[11px] text-cladd-fg-soft">
<th class="w-full pl-3 text-left font-normal" scope="col">Account</th>
<th class="px-2 text-right font-normal" scope="col">Balance</th>
<th class="px-2 text-right font-normal" scope="col">This period</th>
<th
class="hidden pr-3 text-right font-normal sm:table-cell"
scope="col"
>
{TREND_DAYS}
days
</th>
</tr>
</thead>
{#each groups as group (group.code)}
<tbody class="border-cladd-outline border-t">
{#if group.balance && group.change}
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
<th
class="pl-3 text-left text-[12px] text-cladd-fg"
scope="rowgroup"
>
{group.code}
</th>
<td class="whitespace-nowrap px-2 text-right">
{@render balance(group.balance)}
</td>
<td class="whitespace-nowrap px-2 text-right">
{#if toNumber(group.change.amount) !== 0}
<Amount {...group.change} signed />
{/if}
</td>
<td class="hidden pr-3 sm:table-cell">
<div class="flex justify-end">
{@render spark(group.trend)}
</div>
</td>
</tr>
{/if}
{#each group.accounts as account (account.id)}
{@const a = account.attributes}
{@const key = accountKey(account.id)}
{@const open = expanded.includes(account.id)}
<tr
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
key
)}"
id={column.id(key)}
onclick={() => column.point(key)}
>
<td class="max-w-0 pl-1">
<div class="flex min-w-0 items-center">
<button
aria-expanded={open}
aria-label="Last transactions of {a.name}"
class="grid size-6 shrink-0 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:text-cladd-fg"
onclick={() => toggle(account.id)}
type="button"
>
<ChevronRight
class="size-3.5 transition-transform {open
? "rotate-90"
: ""}"
/>
</button>
<a
class="truncate text-cladd-fg hover:underline"
href={showUrl(account.id)}
title={a.name}
>{a.name}</a
>
</div>
</td>
<td class="whitespace-nowrap px-2 text-right">
{@render balance(moneyOf(a, a.current_balance))}
</td>
<td class="whitespace-nowrap px-2 text-right">
{#if toNumber(a.balance_difference) !== 0}
<Amount {...moneyOf(a, a.balance_difference)} signed />
{/if}
</td>
<td class="hidden pr-3 sm:table-cell">
<div class="flex justify-end">
{@render spark(trends.get(a.name) ?? [])}
</div>
</td>
</tr>
{#if open}
{#each recentRows.get(account.id) ?? [] as tx (tx.key)}
<tr
class="h-7 text-[12px] {column.rowClass(tx.key)}"
id={column.id(tx.key)}
onclick={() => column.point(tx.key)}
>
<td class="max-w-0 pl-7" colspan="2">
<a
class="flex min-w-0 items-baseline gap-2 hover:underline"
href={txShowUrl(tx.group.id)}
>
<span class="num w-12 shrink-0 text-cladd-fg-soft"
>{formatDate(tx.date, "short")}</span
>
<span class="truncate text-cladd-fg-soft" title={tx.title}
>{tx.title}</span
>
</a>
</td>
<td class="whitespace-nowrap px-2 text-right">
<Amount {...tx.money} signed />
</td>
<td class="hidden sm:table-cell"></td>
</tr>
{:else}
<tr class="h-7 text-[12px]">
<td class="pl-7 text-cladd-fg-soft" colspan="4">
{recent[expanded.indexOf(account.id)]?.isPending
? ""
: "Nothing in the last year."}
</td>
</tr>
{/each}
{/if}
{/each}
</tbody>
{/each}
</table>
{/if}
</Box>
@@ -4,6 +4,7 @@
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
@@ -16,6 +17,7 @@
type Cur,
hasPrimary,
hrefWith,
mergeable,
money,
seriesPoints,
setCurrency,
@@ -57,34 +59,41 @@
interface Panel {
cur: Cur;
label: string;
series: LineSeries[];
}
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
function points(set: ChartSet): LinePoint[] {
const rate = merged ? (display.rate(curOf(set).code) ?? 1) : 1;
return seriesPoints(set, converted).map((p) => ({ x: p.x, y: p.y * rate }));
}
function sum(group: ChartSet[]): LinePoint[] {
const totals = new Map<string, number>();
for (const set of group) {
for (const p of seriesPoints(set, converted)) {
for (const p of points(set)) {
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
}
}
return [...totals.entries()].map(([x, y]) => ({ x, y }));
}
const panels = $derived.by<Panel[]>(() => {
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
const cur = curOf(group[0]);
function panel(cur: Cur, label: string, group: ChartSet[]): Panel {
const format = money(cur);
if (mode === "net-worth") {
const included = group.filter((s) => !excluded.has(s.label));
return {
cur,
label,
series: [
{
area: true,
format,
key: "net-worth",
label: `Net worth (${cur.code})`,
label: `Net worth (${label})`,
points: sum(included),
strong: true,
},
@@ -93,16 +102,24 @@
}
return {
cur,
label,
series: group.map((s) => ({
area: group.length === 1,
format,
key: s.label,
label: s.label,
points: seriesPoints(s, converted),
points: points(s),
})),
};
});
});
}
const panels = $derived<Panel[]>(
merged
? [panel(display.target, `≈${display.code}`, sets)]
: byCurrency(sets, (s) => curOf(s).code).map(([code, group]) =>
panel(curOf(group[0]), code, group)
)
);
const panelHeight = $derived(
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
);
@@ -161,10 +178,10 @@
{:else}
{#each panels as panel (panel.cur.code)}
<div class="flex flex-col gap-1">
{#if panels.length > 1}
{#if panels.length > 1 || merged}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{panel.cur.code}</span
>{panel.label}</span
>
{/if}
<LineChart
@@ -187,7 +204,7 @@
href="/accounts/asset"
>View your asset accounts</a
>
{#if converted}
{#if converted && !merged}
<span class="ml-auto text-cladd-fg-soft"
>Converted to your primary currency</span
>
@@ -11,10 +11,13 @@
} from "#lib/components/charts/chart.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
byCurrency,
type ChartSet,
type Cur,
mergeable,
money,
setCurrency,
span,
@@ -44,36 +47,50 @@
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
);
const groups = $derived(
byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
const format = money(setCurrency(sets[0]));
const rows: StackRow[] = sets
.map((s) => {
const budgeted = toNumber(s.entries.budgeted);
const spent = Math.abs(toNumber(s.entries.spent));
const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
const id = ids.get(s.label);
const sets = $derived(chart.data ?? []);
const merged = $derived(mergeable(sets.map((s) => s.currency_code)));
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
const format = money(cur);
const totals = new Map<string, { budgeted: number; spent: number }>();
for (const s of list) {
const rate = merged ? (display.rate(s.currency_code) ?? 1) : 1;
const total = totals.get(s.label) ?? { budgeted: 0, spent: 0 };
total.budgeted += toNumber(s.entries.budgeted) * rate;
total.spent += Math.abs(toNumber(s.entries.spent)) * rate;
totals.set(s.label, total);
}
const rows: StackRow[] = [...totals]
.map(([name, { budgeted, spent }]) => {
const id = ids.get(name);
return {
format,
href: id ? `/budgets/show/${id}` : undefined,
key: s.label,
label: s.label,
key: name,
label: name,
note:
budgeted > 0
? `${format(spent)} of ${format(budgeted)}`
: format(spent),
values: {
left: Math.max(0, toNumber(s.entries.left)),
over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
spent: inside,
left: budgeted > 0 ? Math.max(0, budgeted - spent) : 0,
over: budgeted > 0 ? Math.max(0, spent - budgeted) : 0,
spent: budgeted > 0 ? Math.min(spent, budgeted) : spent,
},
};
})
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
const limited = [...totals.values()].some((t) => t.budgeted > 0);
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
return { code, keys, limited, rows };
})
return { code, keys, label, limited, rows };
}
const groups = $derived(
merged
? [group(display.code, `≈${display.code}`, display.target, sets)]
: byCurrency(sets, (s) => s.currency_code).map(([code, list]) =>
group(code, code, setCurrency(list[0]), list)
)
);
const visible = $derived(groups.filter((g) => g.rows.length > 0));
</script>
@@ -101,9 +118,9 @@
{:else}
{#each visible as group (group.code)}
<div class="flex flex-col gap-1">
{#if visible.length > 1 || !group.limited}
{#if visible.length > 1 || !group.limited || merged}
<span class="text-[11px] text-cladd-fg-soft">
<span class="font-semibold tracking-wide">{group.code}</span>
<span class="font-semibold tracking-wide">{group.label}</span>
{#if !group.limited}
· spent, no budget amount set in {group.code}
{/if}
@@ -6,12 +6,15 @@
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
type ChartSet,
type Cur,
hasPrimary,
mergeable,
money,
proportional,
setCurrency,
@@ -34,31 +37,43 @@
const ids = $derived(
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
);
const converted = $derived(hasPrimary(chart.data ?? []));
const sets = $derived(chart.data ?? []);
const converted = $derived(hasPrimary(sets));
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
const groups = $derived(
byCurrency(chart.data ?? [], (s) =>
converted ? setPrimary(s).code : s.currency_code
).map(([code, sets]) => {
const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
const bars: Bar[] = sets
.map((s) => {
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
const totals = new Map<string, number>();
for (const s of list) {
const entries = (converted ? s.pc_entries : s.entries) as Record<
string,
string
>;
const id = ids.get(s.label);
const rate = merged ? (display.rate(curOf(s).code) ?? 1) : 1;
const spent = Math.abs(toNumber(entries?.spent)) * rate;
totals.set(s.label, (totals.get(s.label) ?? 0) + spent);
}
const bars: Bar[] = [...totals]
.map(([name, value]) => {
const id = ids.get(name);
return {
href: id ? `/categories/show/${id}` : NO_CATEGORY,
key: s.label,
label: s.label,
value: Math.abs(toNumber(entries?.spent)),
key: name,
label: name,
value,
};
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars, code, cur };
})
return { bars, code, cur, label };
}
const groups = $derived(
merged
? [group(display.code, `≈${display.code}`, display.target, sets)]
: byCurrency(sets, (s) => curOf(s).code).map(([code, list]) =>
group(code, code, curOf(list[0]), list)
)
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
</script>
@@ -81,10 +96,10 @@
{:else}
{#each visible as group (group.code)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
{#if visible.length > 1 || merged}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>Spent in {group.code}</span
>Spent in {group.label}</span
>
{/if}
<BarChart
+123 -73
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { showUrl } from "#lib/components/accounts/links.js";
import { isDebt, owed, stance } from "#lib/accounts/ledger.js";
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import { formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { accountsQuery, byCurrency, money } from "./data.js";
import { goto } from "$app/navigation";
import { accountsQuery, byCurrency } from "./data.js";
const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end),
@@ -16,90 +18,138 @@
const groups = $derived(
byCurrency(
(liabilities.data ?? []).filter(
(a) =>
a.attributes.liability_type === "debt" &&
toNumber(a.attributes.debt_amount) !== 0
(a) => isDebt(a) && stance(a.attributes) !== "settled"
),
(a) => a.attributes.currency_code ?? ""
).map(([code, list]) => {
const first = list[0].attributes;
const format = money({
const cur = {
code,
decimals: first.currency_decimal_places ?? 2,
symbol: first.currency_symbol ?? code,
});
const bars = list
.map((a) => {
const due = Math.abs(toNumber(a.attributes.debt_amount));
const value =
a.attributes.liability_direction === "credit" ? due : -due;
};
const rows = list
.map((a) => ({
id: a.id,
key: `debt-${a.id}`,
name: a.attributes.name,
value: owed(a.attributes),
}))
.sort((x, y) => y.value - x.value);
return {
color: value > 0 ? tones.income : tones.expense,
href: showUrl(a.id),
key: a.id,
label: a.attributes.name,
value,
code,
cur,
largest: Math.max(...rows.map((r) => Math.abs(r.value))),
net: rows.reduce((s, r) => s + r.value, 0),
rows,
};
})
.sort((x, y) => y.value - x.value);
const owed = bars
.filter((b) => b.value > 0)
.reduce((s, b) => s + b.value, 0);
const owe = bars
.filter((b) => b.value < 0)
.reduce((s, b) => s - b.value, 0);
return { bars, code, format, owe, owed };
})
);
const nets = $derived.by(() => {
const total = display.total(
groups.map((g) => ({ ...g.cur, amount: g.net }))
);
if (total === null) {
return groups.map((g) => ({
code: g.code,
net: g.net,
text: formatMoney({ ...g.cur, amount: Math.abs(g.net) }),
}));
}
const text = formatMoney({ ...total, amount: Math.abs(total.amount) });
return [
{
code: "all",
net: total.amount,
text: total.converted ? `≈${text}` : text,
},
];
});
const column = useColumn(4, () =>
groups.flatMap((g) =>
g.rows.map((r) => ({
edit: () => goto(editUrl(r.id)),
key: r.key,
open: () => goto(showUrl(r.id)),
}))
)
);
</script>
{#if groups.length > 0}
<Box href="/accounts/liabilities" title="Debts with friends">
<div
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData
? "opacity-60"
: ""}"
>
{#each groups as group (group.code)}
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline gap-3 text-[12px]">
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{group.code}</span
>
{#if group.owed > 0}
<span class="text-cladd-fg-soft"
>Owed to me
<span class="num text-income"
>{group.format(group.owed)}</span
></span
>
{/if}
{#if group.owe > 0}
<span class="text-cladd-fg-soft"
>I owe
<span class="num text-expense"
>{group.format(group.owe)}</span
></span
>
{/if}
</div>
<BarChart
bars={group.bars}
format={group.format}
label="Debts with friends in {group.code}"
labelWidth="minmax(5rem, 9rem)"
orientation="horizontal"
/>
</div>
{/each}
</div>
{#snippet footer()}
{#snippet more()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
class="text-brand hover:underline dark:text-(--ff-series-1)"
href="/accounts/liabilities"
>See your liabilities</a
>
{/snippet}
{/snippet}
{#if groups.length > 0}
<Box body="p-0" footer={more} href="/accounts/liabilities" title="Debts">
<div
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
? "opacity-60"
: ""}"
>
{#each nets as g (g.code)}
{#if g.net !== 0}
<span class="text-[12px] text-cladd-fg-soft"
><span
class="num font-semibold text-[15px] {g.net > 0
? "text-income"
: "text-expense"}"
>{g.text}</span
>
{g.net > 0 ? "owed to you" : "owed by you"}</span
>
{/if}
{/each}
</div>
<table class="w-full border-collapse text-[13px]">
{#each groups as g (g.code)}
<tbody class="border-cladd-outline border-t">
{#each g.rows as r, i (r.key)}
<tr
class="h-7 {i > 0
? "border-cladd-outline/60 border-t"
: ""} {column.rowClass(r.key)}"
id={column.id(r.key)}
onclick={() => column.point(r.key)}
>
<td class="w-full max-w-0 pl-3">
<a
class="block truncate text-cladd-fg hover:underline"
href={showUrl(r.id)}
title={r.name}
>{r.name}</a
>
</td>
<td class="px-2">
<div
class="flex h-1.5 w-16 overflow-hidden rounded-full bg-cladd-surface-next"
>
<span
class="h-full rounded-full {r.value > 0
? "bg-income"
: "bg-expense"}"
style:width="{(Math.abs(r.value) / g.largest) * 100}%"
></span>
</div>
</td>
<td
class="num whitespace-nowrap pr-3 pl-2 text-right font-medium {r.value >
0
? "text-income"
: "text-expense"}"
>
{formatMoney({ amount: r.value, ...g.cur }, true)}
</td>
</tr>
{/each}
</tbody>
{/each}
</table>
</Box>
{/if}
+181
View File
@@ -0,0 +1,181 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all, lists } from "#lib/api/lists.js";
import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import { addDays, today } from "#lib/dates.js";
import { formatDate, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { goto } from "$app/navigation";
import { byCurrency, type Cur, rough } from "./data.js";
const DAYS = 7;
const HREF = `/subscriptions?horizon=${DAYS}`;
interface Due {
amount: number;
approx: boolean;
cur: Cur;
date: string;
edit: string;
href: string;
key: string;
name: string;
piggy: boolean;
}
const now = today();
const end = addDays(now, DAYS);
const inWindow = (value: string | null | undefined) => {
const day = (value ?? "").slice(0, 10);
return day >= now && day <= end;
};
const bills = createQuery(() => ({
queryFn: () => all<Bill>("/v1/bills", { end, start: now }),
queryKey: ["bills-due", now, end],
}));
const piggies = createQuery(() => lists.piggyBanks());
const items = $derived.by<Due[]>(() => {
const due = (bills.data ?? [])
.filter((b) => b.attributes.active !== false)
.flatMap((b) => {
const a = b.attributes;
const expected = (a.pay_dates ?? []).filter(inWindow).sort();
const paid = (a.paid_dates ?? []).filter((p) => inWindow(p.date));
const min = toNumber(a.amount_min);
const max = toNumber(a.amount_max);
return expected.slice(paid.length).map((date) => ({
amount: (min + max) / 2,
approx: min !== max,
cur: {
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
symbol: a.currency_symbol ?? "",
},
date: date.slice(0, 10),
edit: `/subscriptions/edit/${b.id}`,
href: `/subscriptions/show/${b.id}`,
key: `due-bill-${b.id}-${date.slice(0, 10)}`,
name: a.name ?? "",
piggy: false,
}));
});
const saving = (piggies.data ?? [])
.filter(
(p) =>
p.attributes.active !== false &&
toNumber(p.attributes.left_to_save) > 0 &&
inWindow(p.attributes.target_date)
)
.map((p) => {
const a = p.attributes;
return {
amount: toNumber(a.left_to_save),
approx: false,
cur: {
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
symbol: a.currency_symbol ?? "",
},
date: (a.target_date ?? "").slice(0, 10),
edit: `/piggy-banks/edit/${p.id}`,
href: `/piggy-banks/show/${p.id}`,
key: `due-piggy-${p.id}`,
name: a.name ?? "",
piggy: true,
};
});
return [...due, ...saving].sort(
(x, y) => x.date.localeCompare(y.date) || x.name.localeCompare(y.name)
);
});
const totals = $derived(
byCurrency(items, (i) => i.cur.code).map(([, list]) => ({
...list[0].cur,
amount: list.reduce((s, i) => s + i.amount, 0),
approx: list.some((i) => i.approx),
}))
);
function dayLabel(date: string) {
if (date === now) {
return "Today";
}
return date === addDays(now, 1) ? "Tomorrow" : formatDate(date, "day");
}
const column = useColumn(3, () =>
items.map((item) => ({
edit: () => goto(item.edit),
key: item.key,
open: () => goto(item.href),
}))
);
</script>
{#snippet more()}
<a class="text-brand hover:underline dark:text-(--ff-series-1)" href={HREF}
>Go to your subscriptions</a
>
{/snippet}
<Box body="p-0" footer={more} href={HREF} title="Next {DAYS} days">
{#if items.length === 0}
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
{bills.isPending ? "" : `Nothing due in the next ${DAYS} days.`}
</p>
{:else}
<div
class="num flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 font-semibold text-[15px] text-cladd-fg"
>
{#if display.total(totals)}
<span
>{rough(
totals,
totals.some((t) => t.approx)
)}</span
>
{:else}
{#each totals as t (t.code)}
<span>{rough([t], t.approx)}</span>
{/each}
{/if}
</div>
<table class="w-full border-collapse text-[13px]">
<tbody>
{#each items as item (item.key)}
<tr
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
item.key
)}"
id={column.id(item.key)}
onclick={() => column.point(item.key)}
>
<td class="whitespace-nowrap pr-2 pl-3 text-cladd-fg-soft">
{dayLabel(item.date)}
</td>
<td class="w-full max-w-0">
<a
class="block truncate text-cladd-fg hover:underline"
href={item.href}
title={item.name}
>{item.name}<span class="text-cladd-fg-soft"
>{item.piggy ? " · piggy bank" : ""}</span
></a
>
</td>
<td
class="num whitespace-nowrap pr-3 pl-2 text-right text-cladd-fg"
>
{rough([{ ...item.cur, amount: item.amount }], item.approx)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
@@ -0,0 +1,183 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Page, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Money from "#lib/components/Money.svelte";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
import { addDays, iso } from "#lib/dates.js";
import { formatDate, formatDateTime } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import {
editUrl,
groupTitle,
showUrl,
sumByCurrency,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
const FETCH = 200;
const SHOW = 10;
const since = reviewed.until;
const sinceDay = addDays(iso(new Date(since)), -1);
const searchHref = `/search?search=${encodeURIComponent(`created_at_after:${sinceDay}`)}`;
const at = (group: { attributes: TransactionGroup }) =>
group.attributes.created_at ?? "";
async function load() {
const page = await api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: FETCH, query: `created_at_after:${sinceDay}` },
});
if (page.data.length < FETCH) {
reviewed.compact(page.data.map((g) => ({ at: at(g), id: g.id })));
}
return page;
}
const inbox = createQuery(() => ({
queryFn: load,
queryKey: ["dashboard-inbox", sinceDay],
refetchOnWindowFocus: true,
}));
const fetched = $derived(inbox.data?.data ?? []);
const entries = $derived(
fetched
.filter((g) => Date.parse(at(g)) > Date.parse(since))
.sort((a, b) => Date.parse(at(b)) - Date.parse(at(a)))
.map((group) => {
const [first] = group.attributes.transactions;
return {
at: at(group),
date: first?.date.slice(0, 10) ?? "",
group,
id: group.id,
money: sumByCurrency(group.attributes.transactions),
note: (first?.notes ?? "").trim().split("\n")[0],
title: groupTitle(group),
};
})
);
const shown = $derived(entries.slice(0, SHOW));
const fresh = $derived(entries.filter((e) => reviewed.isNew(e)).length);
const markAll = () => reviewed.all(entries);
useKeys(() => [
{
group: "Dashboard",
keys: "*",
label: "Mark all entries reviewed",
run: markAll,
},
]);
const column = useColumn(2, () =>
shown.map((e) => ({
edit: () => goto(editUrl(e.id)),
key: `inbox-${e.id}`,
open: () => goto(showUrl(e.id)),
review: () => reviewed.toggle(e),
}))
);
</script>
{#snippet actions()}
{#if fresh > 0}
<button
class="text-brand hover:underline dark:text-(--ff-series-1)"
onclick={markAll}
type="button"
>
Mark all reviewed <span class="kbd">*</span>
</button>
{/if}
{#if entries.length > SHOW}
<a class="ml-auto text-cladd-fg-soft hover:underline" href={searchHref}
>and {entries.length - SHOW} more</a
>
{/if}
{/snippet}
<Box
body="p-0"
footer={fresh > 0 || entries.length > SHOW ? actions : undefined}
href={searchHref}
title="Entered since you last looked"
>
{#snippet tools()}
{#if fresh > 0}
<span class="num text-[12px] text-cladd-fg-soft">{fresh} new</span>
{/if}
{/snippet}
{#if entries.length === 0}
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
{inbox.isPending ? "" : `Nothing new since ${formatDateTime(since)}.`}
</p>
{:else}
<table class="w-full border-collapse text-[13px]">
<tbody>
{#each shown as e, i (e.id)}
{@const key = `inbox-${e.id}`}
{@const isNew = reviewed.isNew(e)}
<tr
class="{i > 0
? "border-cladd-outline/60 border-t"
: ""} {column.rowClass(key)}"
id={column.id(key)}
onclick={() => column.point(key)}
>
<td class="w-7 py-1 pl-2 align-top">
<button
aria-label={isNew ? "Mark reviewed" : "Mark as new"}
aria-pressed={!isNew}
class="grid size-5 place-items-center rounded-full"
onclick={() => reviewed.toggle(e)}
type="button"
>
<span
class="size-2 rounded-full {isNew
? "bg-brand dark:bg-(--ff-series-1)"
: "border border-cladd-fg-softer"}"
></span>
</button>
</td>
<td
class="num w-12 whitespace-nowrap py-1 align-top text-cladd-fg-soft leading-5"
>
{formatDate(e.date, "short")}
</td>
<td class="w-full max-w-0 py-1 pl-2 align-top leading-5">
<a
class="block truncate hover:underline {isNew
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft"}"
href={showUrl(e.id)}
title={e.title}
>{e.title}</a
>
{#if e.note}
<p
class="truncate text-[12px] text-cladd-fg-soft"
title={e.note}
>
{e.note}
</p>
{/if}
</td>
<td
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
>
{#if e.money.length > 0}
<Money parts={e.money} tone={e.money[0].tone} />
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+76 -33
View File
@@ -8,8 +8,15 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import type { Component, Snippet } from "svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
import {
daysBetween,
parse,
periodOf,
shiftPeriod,
today,
} from "#lib/dates.js";
import { currentLocale, formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
@@ -43,35 +50,49 @@
});
const year = $derived(today().slice(0, 4));
const fmt = (e: SummaryEntry, abs = false) =>
formatMoney({
amount: abs ? Math.abs(e.amount) : e.amount,
code: e.code,
decimals: e.decimals,
symbol: e.symbol,
});
const absolute = (list: SummaryEntry[]) =>
list.map((e) => ({ ...e, amount: Math.abs(e.amount) }));
function lines(list: SummaryEntry[], abs = false): string[] {
const parts = abs ? absolute(list) : list;
return display.total(parts)
? [display.text(parts)]
: parts.map((e) => formatMoney(e));
}
function flow(list: SummaryEntry[] | undefined, sign: number) {
const parts = (list ?? []).map((e) => ({
...e,
amount: sign * Math.abs(e.amount),
}));
return display.text(display.total(parts) ? parts : parts.slice(0, 1), true);
}
function compare(
kind: string,
words: [string, string],
upIsGood = true
): Delta | null {
const a = now[kind]?.[0];
const b = a && before[kind]?.find((e) => e.code === a.code);
const sum = display.total(now[kind] ?? []);
const a = sum ?? now[kind]?.[0];
const b = sum
? display.total(before[kind] ?? [])
: a && before[kind]?.find((e) => e.code === a.code);
if (!(a && b) || a.amount === b.amount) {
return null;
}
const diff = a.amount - b.amount;
const text = formatMoney({ ...a, amount: Math.abs(diff) });
return {
good: diff > 0 === upIsGood,
text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
text: `${sum?.converted ? "≈" : ""}${text} ${diff > 0 ? words[0] : words[1]}`,
up: diff > 0,
};
}
const balance = $derived(now.balance ?? []);
const earned = $derived(now.earned?.[0]);
const spent = $derived(now.spent?.[0]);
const earned = $derived(now.earned?.length ? flow(now.earned, 1) : "");
const spent = $derived(now.spent?.length ? flow(now.spent, -1) : "");
const unpaid = $derived(now["bills-unpaid"] ?? []);
const paid = $derived(now["bills-paid"] ?? []);
const left = $derived(now["left-to-spend"] ?? []);
@@ -81,7 +102,23 @@
if (left.some((e) => e.noBudgets)) {
return "none";
}
return left.some((e) => e.amount > 0) ? "ok" : "over";
const sum = display.total(left);
return (sum ? sum.amount > 0 : left.some((e) => e.amount > 0))
? "ok"
: "over";
});
const perDay = $derived.by(() => {
const sum = display.total(left);
if (!sum) {
return left[0]?.sub ?? "";
}
const from = today() < period.start ? period.start : today();
const days = daysBetween(from, period.end) + 1;
if (leftState === "none" || days < 1) {
return "";
}
const amount = formatMoney({ ...sum, amount: sum.amount / days });
return `${sum.converted ? "≈" : ""}${amount}`;
});
const leftTitle = $derived(
leftState === "none"
@@ -97,6 +134,11 @@
}[leftState]
);
const paidShare = $derived.by(() => {
const done = paid.length ? display.total(absolute(paid))?.amount : 0;
const due = unpaid.length ? display.total(absolute(unpaid))?.amount : 0;
if (display.active && done !== undefined && due !== undefined) {
return { paid: done, total: done + due };
}
const code = unpaid[0]?.code ?? paid[0]?.code;
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
@@ -108,20 +150,21 @@
list: SummaryEntry[],
abs = false
)}
{#if list.length === 0}
{@const [main, ...rest] = lines(list, abs)}
{#if main === undefined}
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
0
</div>
{:else}
<div
class="truncate font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
>
{fmt(list[0], abs)}
{main}
</div>
{#if list.length > 1}
{#if rest.length > 0}
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
{#each list.slice(1) as e (e.code)}
<span>{fmt(e, abs)}</span>
{#each rest as text (text)}
<span>{text}</span>
{/each}
</div>
{/if}
@@ -142,7 +185,7 @@
{:else}
<ArrowDown class="size-3.5 shrink-0" />
{/if}
<span class="truncate"
<span
><span class="num">{delta.text}</span>
<span class="text-cladd-fg-soft">than {prevName}</span></span
>
@@ -185,10 +228,10 @@
{#if earned || spent}
<div class="num flex flex-wrap gap-x-2 text-[12px]">
{#if earned}
<span class="text-income">+{fmt(earned, true)}</span>
<span class="text-income">{earned}</span>
{/if}
{#if spent}
<span class="text-expense">−{fmt(spent, true)}</span>
<span class="text-expense">{spent}</span>
{/if}
</div>
{/if}
@@ -203,21 +246,21 @@
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
/>
{/if}
<div class="truncate text-[12px] text-cladd-fg-soft">
Paid:
<span class="num"
>{paid.length ? paid.map((e) => fmt(e, true)).join(", ") : "0"}</span
>
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
<span>Paid:</span>
{#each lines(paid, true) as text (text)}
<span class="num">{text}</span>
{:else}
<span class="num">0</span>
{/each}
</div>
{/snippet}
{#snippet leftToSpend()}
{@render values(left)}
{#if left[0]?.sub}
<div class="truncate text-[12px] text-cladd-fg-soft">
{leftState === "none" ? "" : "Per day: "}<span class="num"
>{left[0].sub}</span
>
{#if perDay}
<div class="text-[12px] text-cladd-fg-soft">
{leftState === "none" ? "" : "Per day: "}<span class="num">{perDay}</span>
</div>
{/if}
{@render change(compare("left-to-spend", ["more", "less"]))}
@@ -1,146 +0,0 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
currency,
type InsightEntry,
money,
proportional,
span,
} from "./data.js";
const MAX_BARS = 24;
const NARROW_BARS = 8;
let { kind }: { kind: "expense" | "revenue" } = $props();
const path = $derived(
kind === "expense"
? "/v1/insight/expense/expense"
: "/v1/insight/income/revenue"
);
const insight = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<InsightEntry[]>(path, {
query: span(period.start, period.end),
}),
queryKey: ["insight", kind, period.start, period.end],
}));
const title = $derived(
kind === "expense" ? "Expense accounts" : "Revenue accounts"
);
const verb = $derived(kind === "expense" ? "Spent" : "Earned");
function fold(bars: Bar[]): Bar[] {
if (bars.length <= MAX_BARS) {
return bars;
}
const rest = bars.slice(MAX_BARS - 1);
return [
...bars.slice(0, MAX_BARS - 1),
{
key: "other",
label: `Other (${rest.length})`,
value: rest.reduce((sum, b) => sum + b.value, 0),
},
];
}
const groups = $derived(
byCurrency(insight.data ?? [], (e) => e.currency_code).map(
([code, list]) => {
const cur = currency(code);
const bars = list
.map((e) => ({
href: e.id ? showUrl(e.id) : undefined,
key: e.id ?? e.name ?? "",
label: e.name ?? "",
value: Math.abs(toNumber(e.difference)),
}))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars: fold(bars), code, cur };
}
)
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
const wide = $derived(
visible.filter((g, i) => i === 0 || g.bars.length > NARROW_BARS)
);
const narrow = $derived(visible.filter((g) => !wide.includes(g)));
</script>
{#snippet chart(
group: (typeof groups)[number],
height: number
)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
<span class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{verb}
in {group.code}</span
>
{/if}
<BarChart
axisFormat={axis(group.cur)}
bars={group.bars}
color={kind === "expense" ? tones.expense : tones.income}
format={money(group.cur)}
{height}
label="{verb} per {kind} account in {group.code}"
name={verb}
/>
</div>
{/snippet}
<Box href="/accounts/{kind}" {title}>
<div
class="flex flex-col gap-5 transition-opacity {insight.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if insight.isPending}
<div class="h-60"></div>
{:else if insight.isError}
<p class="text-expense">{insight.error.message}</p>
{:else if visible.length === 0}
<p class="py-8 text-center text-cladd-fg-soft">
{kind === "expense"
? "Nothing spent in this period."
: "Nothing earned in this period."}
</p>
{:else}
{#each [wide, narrow] as set, i (i)}
{#if set.length > 0}
<div
class="grid gap-x-6 gap-y-5 {i === 1
? "md:grid-cols-(--cols)"
: ""}"
style:--cols={proportional(set.map((g) => g.bars.length))}
>
{#each set as group (group.code)}
{@render chart(group, i === 0 ? 240 : 180)}
{/each}
</div>
{/if}
{/each}
{/if}
</div>
{#snippet footer()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
href="/accounts/{kind}"
>See your {kind} accounts</a
>
{/snippet}
</Box>
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import Box from "#lib/components/Box.svelte";
import {
addDays,
daysBetween,
parse,
type Range,
shiftPeriod,
today,
} from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { median } from "#lib/reports/typical.js";
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
let { class: className = "" }: { class?: string } = $props();
const MONTHS = 6;
const MIN_MONTHS = 3;
const SUFFIX: Record<string, string> = {
few: "rd",
one: "st",
other: "th",
two: "nd",
};
const ordinals = new Intl.PluralRules("en-US", { type: "ordinal" });
const now = today();
const cutoff = $derived(period.end < now ? period.end : now);
const visible = $derived(cutoff >= period.start);
const ranges = $derived.by(() => {
const days = daysBetween(period.start, cutoff);
const list: Range[] = [{ end: cutoff, start: period.start }];
let { range } = period;
for (let i = 0; i < MONTHS; i += 1) {
range = shiftPeriod(range, period.view, -1);
const end = addDays(range.start, days);
list.push({ end: end < range.end ? end : range.end, start: range.start });
}
return list;
});
const pace = createQuery(() => ({
enabled: visible,
placeholderData: keepPreviousData,
queryFn: () =>
Promise.all(
ranges.map((r) =>
api<InsightEntry[]>("/v1/insight/expense/total", {
query: span(r.start, r.end),
})
)
),
queryKey: ["dashboard-pace", ranges.map((r) => r.start).join(), cutoff],
}));
const total = (list: InsightEntry[], code: string) =>
Math.abs(
list
.filter((e) => e.currency_code === code)
.reduce((s, e) => s + toNumber(e.difference), 0)
);
const rows = $derived.by(() => {
const [current = [], ...past] = pace.data ?? [];
const codes = [
...new Set([current, ...past].flat().map((e) => e.currency_code)),
];
return byCurrency(codes, (code) => code)
.map(([code]) => {
const history = past
.map((list) => total(list, code))
.filter((v) => v > 0);
const typical = history.length >= MIN_MONTHS ? median(history) : null;
const spent = total(current, code);
const cur = currency(code);
return {
code,
format: (amount: number) => formatMoney({ amount, ...cur }),
spent,
typical,
};
})
.filter((r) => r.spent > 0 || r.typical !== null);
});
const title = $derived.by(() => {
if (period.view !== "1M") {
return `Spent by ${formatDate(cutoff, "short")}`;
}
const day = parse(cutoff).getDate();
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
});
const all = $derived.by(() => {
const lists = pace.data ?? [];
const sums = lists.map((list) =>
display.total(
list.map((e) => ({ amount: e.difference, code: e.currency_code }))
)
);
if (!display.active || lists.some((list, i) => list.length && !sums[i])) {
return null;
}
const [spent = 0, ...past] = sums.map((sum) => Math.abs(sum?.amount ?? 0));
const history = past.filter((v) => v > 0);
const approx = sums.some((sum) => sum?.converted);
return {
code: "All",
format: (amount: number) =>
`${approx ? "≈" : ""}${formatMoney({ ...display.target, amount, decimals: 0 })}`,
spent,
typical: history.length >= MIN_MONTHS ? median(history) : null,
};
});
const lines = $derived(all ? [all] : rows);
</script>
{#if visible && rows.length > 0}
<Box body="p-0" class={className} {title}>
{#snippet tools()}
<span class="text-[12px] text-cladd-fg-soft"
>Typical: median of {MONTHS} months</span
>
{/snippet}
<table
class="w-full border-collapse text-[13px] transition-opacity {pace.isPlaceholderData
? "opacity-60"
: ""}"
>
<thead>
<tr class="h-7 text-[11px] text-cladd-fg-soft">
<th class="pl-3 text-left font-normal" scope="col">Currency</th>
<th class="px-2 text-right font-normal" scope="col">Spent</th>
<th class="px-2 text-right font-normal" scope="col">Typical</th>
<th class="px-2 text-right font-normal" scope="col">Difference</th>
<th class="hidden w-full pr-3 font-normal sm:table-cell" scope="col">
<span class="sr-only">Spent against typical</span>
</th>
</tr>
</thead>
<tbody>
{#each lines as r (r.code)}
{@const diff = r.typical === null ? null : r.spent - r.typical}
{@const scale = Math.max(r.spent, r.typical ?? 0) || 1}
<tr class="h-7 border-cladd-outline/60 border-t">
<th
class="pl-3 text-left font-semibold text-[12px] text-cladd-fg"
scope="row"
>
{r.code}
</th>
<td
class="num whitespace-nowrap px-2 text-right font-semibold text-cladd-fg"
>
{r.format(r.spent)}
</td>
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{r.typical === null ? "n/a" : r.format(r.typical)}
</td>
<td
class="num px-2 text-right sm:whitespace-nowrap {diff !== null &&
diff > 0
? "text-expense"
: "text-income"}"
>
{#if diff !== null}
{r.format(Math.abs(diff))}
{diff > 0 ? "over" : "under"}
{/if}
</td>
<td class="hidden pr-3 pl-2 sm:table-cell">
{#if r.typical !== null}
<div
class="relative h-2 min-w-24 max-w-80 rounded-full bg-cladd-surface-next"
>
<span
class="absolute inset-y-0 left-0 rounded-full {diff !==
null && diff > 0
? "bg-expense"
: "bg-brand"}"
style:width="{(r.spent / scale) * 100}%"
></span>
<span
class="absolute -top-1 -bottom-1 w-0.5 -translate-x-1/2 rounded-full bg-cladd-fg"
style:left="{(r.typical / scale) * 100}%"
></span>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</Box>
{/if}
@@ -4,7 +4,8 @@
import type { PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { formatMoney, percent, toNumber } from "#lib/format.js";
import { percent, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
const piggies = createQuery(() => lists.piggyBanks());
@@ -18,12 +19,14 @@
);
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
formatMoney({
display.text([
{
amount,
code: p.currency_code,
decimals: p.currency_decimal_places,
symbol: p.currency_symbol,
});
},
]);
</script>
{#if saving.length > 0}
@@ -4,9 +4,11 @@
import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { parseSummary, span, summaryQuery } from "./data.js";
import { parseSummary, rough, span, summaryQuery } from "./data.js";
const DUE_LIMIT = 6;
@@ -36,6 +38,30 @@
)
.filter((m) => m.total > 0)
);
const lines = $derived.by(() => {
const paid = meters.map((m) => ({ ...m.cur, amount: m.paid }));
const left = meters.map((m) => ({ ...m.cur, amount: m.total - m.paid }));
const sumPaid = display.total(paid);
const sumLeft = display.total(left);
if (sumPaid && sumLeft) {
return [
{
key: display.code,
left: display.text(left),
max: sumPaid.amount + sumLeft.amount,
paid: display.text(paid),
value: sumPaid.amount,
},
];
}
return meters.map((m, i) => ({
key: m.cur.code,
left: formatMoney(left[i]),
max: m.total,
paid: formatMoney(paid[i]),
value: m.paid,
}));
});
const inPeriod = (dates: (string | null | undefined)[]) =>
dates
.map((d) => (d ?? "").slice(0, 10))
@@ -50,7 +76,7 @@
(b.attributes.paid_dates ?? []).map((p) => p.date)
).length;
const next = expected[paid];
if (!next) {
if (!next || next >= today()) {
return [];
}
const amount =
@@ -61,16 +87,6 @@
})
.sort((x, y) => x.date.localeCompare(y.date))
);
const fmt = (
amount: number,
c: { code: string; symbol: string; decimals: number }
) =>
formatMoney({
amount,
code: c.code,
decimals: c.decimals,
symbol: c.symbol,
});
</script>
{#if (bills.data?.length ?? 0) > 0}
@@ -84,25 +100,23 @@
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each meters as m (m.cur.code)}
{#each lines as m (m.key)}
<li class="flex flex-col gap-1">
<div
class="flex items-baseline justify-between gap-2 text-[12px]"
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
><span class="num text-cladd-fg">{m.paid}</span>
paid</span
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg"
>{fmt(m.total - m.paid, m.cur)}</span
>
><span class="num text-cladd-fg">{m.left}</span>
to pay</span
>
</div>
<Meter
max={m.total}
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
max={m.max}
segments={[{ color: "var(--ff-series-3)", value: m.value }]}
/>
</li>
{/each}
@@ -111,7 +125,7 @@
{#if due.length > 0}
<div class="flex flex-col">
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
>Still to pay this period</span
>Not paid yet</span
>
<ul class="flex flex-col">
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
@@ -127,12 +141,14 @@
>{d.bill.attributes.name}</span
>
<span class="num text-cladd-fg-soft"
>~{formatMoney({
>{rough([
{
amount: d.amount,
code: d.bill.attributes.currency_code,
decimals: d.bill.attributes.currency_decimal_places,
symbol: d.bill.attributes.currency_symbol,
})}</span
},
])}</span
>
</a>
</li>
+20 -1
View File
@@ -2,7 +2,13 @@ import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
import { addDays, today } from "#lib/dates.js";
import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
import {
compactMoney,
formatMoney,
type Money,
toNumber,
} from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
@@ -89,6 +95,19 @@ export function byCurrency<T>(
);
}
export function mergeable(codes: string[]): boolean {
return (
display.active &&
codes.some((code) => code !== display.code) &&
codes.every((code) => display.rate(code) !== null)
);
}
export function rough(parts: Money[], approx = true): string {
const text = display.text(parts);
return approx && !text.startsWith("≈") ? `~${text}` : text;
}
export function parseSummary(
raw: Record<string, BasicSummaryEntry> | undefined
): Record<string, SummaryEntry[]> {
@@ -0,0 +1,161 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Box from "#lib/components/Box.svelte";
import { setParam } from "#lib/components/categories/url.js";
import {
type Lane,
type StackRow,
tones,
} from "#lib/components/charts/chart.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import type { Range } from "#lib/dates.js";
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import type { Facet } from "#lib/dimension/spec.js";
import { lanesOf } from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { longMonth, monthRange } from "#lib/reports/board.js";
import { page } from "$app/state";
import Headline from "./Headline.svelte";
import Inside from "./Inside.svelte";
import Ledger from "./Ledger.svelte";
const LANE_HEIGHTS = [180, 112, 88];
let {
model,
noun,
accountId,
shape = (list: Lane[]) => list,
onmonth = (month: Range) => period.set(month),
lead,
extra,
below,
header,
}: {
model: DimensionModel;
noun: string;
accountId?: string;
shape?: (lanes: Lane[]) => Lane[];
onmonth?: (range: Range) => void;
lead?: Snippet;
extra?: Snippet;
below?: Snippet;
header?: Snippet<[Lane, StackRow | undefined]>;
} = $props();
const height = $derived(
LANE_HEIGHTS[Math.min(model.codes.length, LANE_HEIGHTS.length) - 1] ?? 88
);
const lanes = $derived(
shape(
lanesOf(
model.flows,
model.months,
model.codes,
model.dir,
model.norms,
height
)
)
);
const facets = $derived(model.spec.facets);
const facet = $derived(
facets.find((f) => f.id === page.url.searchParams.get("facet")) ?? facets[0]
);
const only = $derived.by(() => {
const value = page.url.searchParams.get("only") ?? "";
const cut = value.indexOf(":");
const found = facets.find((f) => f.id === value.slice(0, cut));
return found && cut > 0
? { facet: found, key: value.slice(cut + 1) }
: null;
});
const narrowed = $derived.by(() => {
if (!only) {
return null;
}
const ids = new Set<string>();
let label = only.key;
for (const flow of model.periodFlows) {
const key = only.facet.of(flow).find((k) => k.key === only.key);
if (key) {
ids.add(flow.group);
({ label } = key);
}
}
return { groups: model.groups.filter((g) => ids.has(g.id)), label };
});
const pick = (f: Facet) =>
setParam("facet", f.id === facets[0]?.id ? "" : f.id);
const clear = () => setParam("only", "");
useKeys(() =>
only
? [
{
group: "Inside",
keys: "Escape",
label: "Show all transactions again",
run: clear,
},
]
: []
);
</script>
<Headline {lead} {model} {noun} />
{@render extra?.()}
<div class="grid gap-3 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
<Box title={model.everything ? "All months" : "13 months"}>
{#if lanes.length > 0}
<Lanes
cursor={model.focus}
dotColor={model.dir === -1 ? tones.income : tones.expense}
dotLabel={model.dir === -1 ? "in" : "out"}
{header}
label="Months by currency"
{lanes}
moreIsGood={model.dir === 1}
onselect={(month) => onmonth(monthRange(month))}
partial={model.partial}
title={(row) => longMonth(row.key)}
/>
{:else if !model.loading}
<p class="py-10 text-center text-[12px] text-cladd-fg-soft">
No spending or income here in these months.
</p>
{/if}
{@render below?.()}
</Box>
<Inside
class="lg:[contain:size]"
current={only?.facet.id === facet.id ? only.key : null}
{facet}
{model}
onfacet={pick}
onnarrow={(f, row) =>
setParam(
"only",
only?.facet.id === f.id && only.key === row.key
? ""
: `${f.id}:${row.key}`
)}
/>
</div>
<div class="mt-3">
<Ledger
{accountId}
empty={model.everything
? "No transactions."
: "No transactions in this period."}
groups={model.groups}
loading={model.loading}
{narrowed}
onclear={clear}
/>
</div>
@@ -0,0 +1,144 @@
<script lang="ts">
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
import { untrack } from "svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { type FacetRow, fmt } from "#lib/dimension/stats.js";
import { percent } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
const GROUP = "Inside";
let {
rows,
loading,
active,
current,
marker,
label,
onnarrow,
onleave,
}: {
rows: FacetRow[];
loading: boolean;
active: boolean;
current: string | null;
marker: number;
label: string;
onnarrow: (row: FacetRow) => void;
onleave: () => void;
} = $props();
function tint(i: number, row: FacetRow) {
if (active && nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return row.key === current ? "bg-row-selected" : "hover:bg-row-hover";
}
const top = $derived(Math.max(...rows.map((row) => row.share), 0) || 1);
const nav: ListNav<FacetRow> = new ListNav<FacetRow>({
extra: () => [
{
group: GROUP,
keys: "Enter",
label: "Narrow the transactions to this row",
run: () => nav.current && onnarrow(nav.current),
},
{
group: GROUP,
keys: "o",
label: "Open the page of this row",
run: () => nav.current?.href && goto(nav.current.href),
},
{
group: GROUP,
keys: "Escape",
label: "Back to the transactions",
run: onleave,
},
],
id: "facet",
items: () => rows,
key: (row) => row.key,
});
useKeys(() => (active ? nav.bindings() : []));
$effect(() => {
if (active) {
untrack(() =>
nav.move(
Math.max(
0,
rows.findIndex((row) => row.key === current)
)
)
);
}
});
</script>
{#if rows.length === 0 && !loading}
<p class="px-3 py-6 text-center text-[12px] text-cladd-fg-soft">
Nothing in this period.
</p>
{:else if rows.length > 0}
<ul
aria-label={label}
class="grid min-h-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)_auto_auto_auto] overflow-y-auto py-1 text-[12px] sm:grid-cols-[minmax(6rem,1fr)_minmax(2rem,7rem)_auto_auto_auto_auto]"
>
{#each rows as row, i (row.key)}
<li
class="col-span-full grid h-7 grid-cols-subgrid items-center gap-x-2.5 px-3 {tint(
i,
row
)}"
id={nav.rowId(i)}
>
<button
class="truncate text-left text-cladd-fg hover:underline"
onclick={() => onnarrow(row)}
title={row.label}
type="button"
>
{row.label}
</button>
<span class="relative h-2.5 max-sm:hidden">
<span
class="absolute inset-y-0 left-0 rounded-r-sm bg-brand"
style:width="max(1px, {(row.share / top) * 100}%)"
></span>
</span>
<span class="num truncate text-right text-cladd-fg">
{row.sums.map((sum) => fmt(sum.code, sum.amount, 0)).join(" + ")}
</span>
<span class="num w-9 text-right text-cladd-fg-soft"
>{percent(row.share)}</span
>
<span class="max-sm:hidden">
<Sparkline
color="var(--ff-brand)"
height={18}
{marker}
values={row.spark}
width={56}
/>
</span>
{#if row.href}
<a
aria-label="Open {row.label}"
class="text-cladd-fg-softer hover:text-cladd-fg"
href={row.href}
>
<ArrowUpRight class="size-3.5" />
</a>
{:else}
<span></span>
{/if}
</li>
{/each}
</ul>
{/if}
@@ -0,0 +1,148 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { daysBetween } from "#lib/dates.js";
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import { counted, fmt, type Money } from "#lib/dimension/stats.js";
import { formatDate, formatNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { monthOf } from "#lib/reports/board.js";
import { isApprox } from "#lib/reports/display.js";
import { versusText } from "#lib/reports/typical.js";
const WEEK = 7;
const SAME = 0.03;
let {
model,
noun,
lead,
}: { model: DimensionModel; noun: string; lead?: Snippet } = $props();
const facts = $derived(model.facts);
const when = $derived.by(() => {
if (!model.range) {
return "all time";
}
if (model.focus === monthOf(model.now) && period.isCurrent) {
return "this month";
}
return `in ${period.label}`;
});
const days = $derived.by(() => {
const start = model.range?.start ?? model.history.first;
const end =
model.range && model.range.end < model.now ? model.range.end : model.now;
return start ? daysBetween(start, end) + 1 : 0;
});
const perWeek = $derived(days >= WEEK ? (facts.count * WEEK) / days : null);
const main = $derived(model.norms[0]);
const typicals = $derived(model.norms.filter((n) => n.typical > 0));
const active = $derived(
model.typicalMonths.filter((month) =>
model.main.some((flow) => monthOf(flow.date) === month)
).length
);
const good = $derived(model.dir === 1);
function tone(ratio: number | null) {
if (ratio === null || Math.abs(ratio) < SAME) {
return "text-cladd-fg-soft";
}
return ratio > 0 === good ? "text-income" : "text-expense";
}
</script>
{#snippet amounts(
sums: Money[],
big: boolean
)}
{#each sums as sum, i (sum.code)}
{#if i > 0}
<span class="text-cladd-fg-soft">+</span>
{/if}
<span class="num font-semibold text-cladd-fg {big ? "text-[22px]" : ""}"
>{fmt(sum.code, sum.amount, 0)}</span
>
{/each}
{/snippet}
<div class="flex flex-col gap-1 pb-3">
{#if lead}
{@render lead()}
{:else if !model.loading}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
>
{#if facts.count > 0}
{@render amounts(facts.sums, true)}
<span>{when}</span>
<span>· {counted(facts.count, noun)}</span>
{#if facts.median}
<span
>· median
<span class="num text-cladd-fg"
>{fmt(facts.median.code, facts.median.amount, 0)}</span
></span
>
{/if}
{#if perWeek !== null}
<span
>·
<span class="num text-cladd-fg"
>{formatNumber(perWeek, perWeek < 10 ? 1 : 0)}</span
>
per week</span
>
{/if}
{:else}
<span class="font-semibold text-[22px] text-cladd-fg"
>Nothing {when}</span
>
{#if model.history.last}
<span>· last {formatDate(model.history.last)}</span>
{/if}
{/if}
</p>
{/if}
{#if !model.loading && main}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[13px] text-cladd-fg-soft"
>
{#if lead}
{#if facts.count > 0}
{@render amounts(facts.sums, false)}
<span>{when} ·</span>
{:else}
<span>nothing {when} ·</span>
{/if}
{/if}
{#if typicals.length > 0}
<span>typical month</span>
{@render amounts(
typicals.map((n) => ({ amount: n.typical, code: n.code })),
false
)}
{#if main.projected !== null}
<span
>· on pace for {isApprox(main.code) ? "" : "≈"}
<span class="num font-semibold text-cladd-fg"
>{fmt(main.code, main.projected, 0)}</span
>,</span
>
{:else if main.ratio !== null}
<span>·</span>
{/if}
{#if main.ratio !== null}
<span class="font-semibold {tone(main.ratio)}"
>{versusText(main.ratio)}</span
>
{/if}
{:else if model.typicalMonths.length > 0}
<span
>in {active} of the last {model.typicalMonths.length}
{model.typicalMonths.length === 1 ? "month" : "months"}</span
>
{/if}
</p>
{/if}
</div>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
import type { Facet } from "#lib/dimension/spec.js";
import { type FacetRow, facetRows } from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js";
import FacetRows from "./FacetRows.svelte";
let {
model,
facet,
current,
onfacet,
onnarrow,
class: className = "",
}: {
model: DimensionModel;
facet: Facet;
current: string | null;
onfacet: (facet: Facet) => void;
onnarrow: (facet: Facet, row: FacetRow) => void;
class?: string;
} = $props();
let focused = $state(false);
let section = $state<HTMLElement>();
const labelOf = (f: Facet) =>
model.dir === 1 && f.incomeLabel ? f.incomeLabel : f.label;
const rows = $derived(
facetRows(
facet,
model.periodMain,
model.main,
model.months,
model.codes,
model.weight
)
);
const marker = $derived(model.months.indexOf(model.focus ?? ""));
useKeys(() =>
model.spec.facets.map((f, i) => ({
group: "Inside",
keys: String(i + 1),
label: `Inside: ${labelOf(f)}`,
run: () => {
onfacet(f);
focused = true;
},
}))
);
</script>
<svelte:window
onpointerdown={(event) => {
if (focused && !section?.contains(event.target as Node)) {
focused = false;
}
}}
/>
<section
aria-label="Inside"
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {focused
? "outline-2 outline-brand"
: ""} {className}"
bind:this={section}
>
<header
class="flex min-h-10 shrink-0 items-center gap-1 overflow-x-auto border-cladd-outline border-b px-2"
role="tablist"
>
{#each model.spec.facets as f, i (f.id)}
<button
aria-selected={f.id === facet.id}
class="flex h-7 shrink-0 items-center gap-1.5 rounded-cladd-sm px-2 text-[13px] {f.id ===
facet.id
? "bg-cladd-surface-next font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => onfacet(f)}
role="tab"
type="button"
>
<span class="kbd">{i + 1}</span>
{labelOf(f)}
</button>
{/each}
</header>
{#key facet.id}
<FacetRows
active={focused}
{current}
label={labelOf(facet)}
loading={model.loading}
{marker}
onleave={() => (focused = false)}
onnarrow={(row) => {
focused = false;
onnarrow(facet, row);
}}
{rows}
/>
{/key}
</section>
@@ -0,0 +1,70 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { session } from "#lib/session.svelte.js";
import type { Group } from "#lib/transactions/split.js";
import { page } from "$app/state";
let {
groups,
narrowed,
onclear,
loading = false,
accountId,
empty,
}: {
groups: Group[];
narrowed: { groups: Group[]; label: string } | null;
onclear: () => void;
loading?: boolean;
accountId?: string;
empty: string;
} = $props();
const shown = $derived(narrowed?.groups ?? groups);
const size = $derived(session.pageSize);
const pages = $derived(Math.max(1, Math.ceil(shown.length / size)));
const current = $derived(
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
);
const slice = $derived(shown.slice((current - 1) * size, current * size));
function href(n: number) {
const url = new URL(page.url.href);
url.searchParams.set("page", String(n));
return url.pathname + url.search;
}
</script>
<Box body="p-0" title="Transactions">
{#snippet tools()}
{#if narrowed}
<span class="text-[12px] text-cladd-fg-soft"
><span class="font-semibold text-cladd-fg">{narrowed.label}</span>
<span class="num"
>{narrowed.groups.length}
of {groups.length}</span
></span
>
<button
aria-label="Show all {groups.length}"
class="kbd hover:text-cladd-fg"
onclick={onclear}
type="button"
>
Esc
</button>
{:else}
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
{/if}
<Pager {href} keys page={current} {pages} />
{/snippet}
<TransactionTable
context={{ accountId }}
{empty}
groups={slice}
id="dimension-tx"
{loading}
/>
</Box>
@@ -0,0 +1,56 @@
<script lang="ts">
import { fmt, type LimitFact } from "#lib/dimension/stats.js";
import { percent } from "#lib/format.js";
let { facts }: { facts: LimitFact[] } = $props();
</script>
<div class="-mt-1 flex flex-col gap-0.5 pb-3 text-[13px] text-cladd-fg-soft">
{#each facts as fact (fact.id)}
{@const expected =
fact.day === null ? null : (fact.amount * fact.day) / fact.days}
{@const left = fact.amount - fact.spent}
<p class="flex flex-wrap items-baseline gap-x-1.5">
<span
>limit
<span class="num font-semibold text-cladd-fg"
>{fmt(fact.code, fact.amount, 0)}</span
></span
>
<span
>· spent
<span class="num text-cladd-fg"
>{percent(fact.amount > 0 ? fact.spent / fact.amount : 0)}</span
></span
>
{#if fact.day !== null && expected !== null}
<span>· day {fact.day} of {fact.days} →</span>
{#if fact.spent > expected}
<span class="font-semibold text-expense"
>{fmt(fact.code, fact.spent - expected, 0)}
over pace</span
>
{:else}
<span class="font-semibold text-income">under pace</span>
{/if}
{/if}
{#if left >= 0}
<span
>·
<span class="num text-cladd-fg">{fmt(fact.code, left, 0)}</span>
left</span
>
{:else}
<span
>·
<span class="num font-semibold text-expense"
>{fmt(fact.code, -left, 0)}</span
>
overspent</span
>
{/if}
</p>
{:else}
<p>no limit for this period</p>
{/each}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { fmt, type StripCell } from "#lib/dimension/stats.js";
import { longMonth, shortMonth } from "#lib/reports/board.js";
let {
strips,
budgetId,
cursor,
}: {
strips: Map<string, StripCell[]>;
budgetId: string;
cursor: string | null;
} = $props();
</script>
{#snippet meter(
cell: StripCell,
code: string
)}
{@const over = cell.spent > cell.amount}
<span
class="text-[11px] max-sm:hidden {cell.month === cursor
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft"}"
>{shortMonth(cell.month)}</span
>
<span class="relative h-1 w-full rounded-full bg-cladd-surface-next">
{#if cell.id}
<span
class="absolute inset-y-0 left-0 rounded-full {over
? "bg-expense"
: "bg-brand"}"
style:width="{Math.min(1, cell.spent / (cell.amount || 1)) * 100}%"
></span>
{/if}
</span>
<span class="sr-only"
>{cell.id
? `${fmt(code, cell.spent, 0)} of ${fmt(code, cell.amount, 0)}`
: "no limit"}</span
>
{/snippet}
{#each [...strips] as [code, cells] (code)}
<nav
aria-label="Limits in {code}"
class="mt-2 grid gap-1 border-cladd-outline border-t pt-2"
style:grid-template-columns="2.5rem repeat({cells.length}, minmax(0, 1fr))"
>
<span class="self-center font-semibold text-[12px] text-cladd-fg"
>{code}</span
>
{#each cells as cell (cell.month)}
{#if cell.id}
<a
class="flex flex-col items-center gap-1 rounded-cladd-sm px-1 py-0.5 hover:bg-row-hover {cell.month ===
cursor
? "bg-row-cursor"
: ""}"
href="/budgets/show/{budgetId}/{cell.id}"
title="{longMonth(cell.month)}: {fmt(code, cell.spent, 0)} of {fmt(
code,
cell.amount,
0
)}"
>
{@render meter(cell, code)}
</a>
{:else}
<span class="flex flex-col items-center gap-1 px-1 py-0.5">
{@render meter(cell, code)}
</span>
{/if}
{/each}
</nav>
{/each}
@@ -0,0 +1,90 @@
<script lang="ts">
import { daysBetween } from "#lib/dates.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js";
import { merchantSpec } from "#lib/dimension/spec.js";
import { counted, factsOf, fmt } from "#lib/dimension/stats.js";
import { formatDate, formatNumber } from "#lib/format.js";
import Dimension from "./Dimension.svelte";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
const model = new DimensionModel(() => ({
allTime: true,
everything,
spec: merchantSpec(id),
}));
const history = $derived(
factsOf(
model.main.filter((flow) => flow.date <= model.now),
model.codes
)
);
const noun = $derived(model.dir === -1 ? "visits" : "payments");
const every = $derived(
history.count > 1
? daysBetween(history.first, history.last) / (history.count - 1)
: null
);
const bill = $derived(model.main.find((flow) => flow.bill)?.bill ?? null);
</script>
{#snippet lead()}
{#if !model.loading}
<p
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
>
{#if history.count > 0}
{#each history.sums as sum, i (sum.code)}
{#if i > 0}
<span>+</span>
{/if}
<span class="num font-semibold text-[22px] text-cladd-fg"
>{fmt(sum.code, sum.amount, 0)}</span
>
{/each}
<span>all time · {counted(history.count, noun)}</span>
{#if history.median}
<span
>· median
<span class="num text-cladd-fg"
>{fmt(history.median.code, history.median.amount, 0)}</span
></span
>
{/if}
{#if every !== null}
<span
>· every
<span class="num text-cladd-fg"
>{formatNumber(every, every < 10 ? 1 : 0)}</span
>
days</span
>
{/if}
<span
>· last
<span class="text-cladd-fg"
>{formatDate(history.last, "short")}</span
></span
>
{#if bill}
<span
>· subscription
<a
class="text-brand hover:underline"
href="/subscriptions/show/{bill.id}"
>{bill.name}</a
></span
>
{/if}
{:else}
<span class="font-semibold text-[22px] text-cladd-fg"
>No {noun} yet</span
>
{/if}
</p>
{/if}
{/snippet}
<Dimension accountId={id} {lead} {model} {noun} />
+46 -35
View File
@@ -10,6 +10,7 @@
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { formatMoney } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js";
@@ -51,15 +52,16 @@
withdrawal: "withdrawal",
};
const FIELDS: [string, string, string][] = [
["alt+d", "description", "description"],
["alt+f", "source", "source account"],
["alt+t", "destination", "destination account"],
["alt+w", "date", "date"],
["alt+m", "amount", "amount"],
["alt+k", "category", "category"],
["alt+b", "budget", "budget"],
["alt+g", "tags", "tags"],
["alt+r", "notes", "notes"],
["d", "description", "description"],
["f", "source", "source account"],
["t", "destination", "destination account"],
["w", "date", "date"],
["m", "amount", "amount"],
["c", "category", "category"],
["b", "budget", "budget"],
["s", "bill", "subscription"],
["#", "tags", "tags"],
["r", "notes", "notes"],
];
const INLINE = new Set([
"description",
@@ -103,6 +105,7 @@
const originalType = start.type;
let form = $state<Form>(start);
let lastSplit = start.splits[0].key;
let saved = $state(untrack(() => group));
let snapshot = JSON.stringify(start);
let errors = $state<Errors>({});
@@ -203,9 +206,17 @@
tick().then(() => focusField("description", false));
});
function splitOf(target: EventTarget | null): number | null {
const holder =
target instanceof Element ? target.closest("[data-split]") : null;
return holder ? Number(holder.getAttribute("data-split")) : null;
}
function currentKey(): number {
const holder = document.activeElement?.closest("[data-split]");
return Number(holder?.getAttribute("data-split") ?? form.splits[0].key);
const key = splitOf(document.activeElement) ?? lastSplit;
return form.splits.some((split) => split.key === key)
? key
: form.splits[0].key;
}
function focusField(field: string, select = true) {
@@ -451,29 +462,25 @@
},
{
group: "Editor",
input: true,
keys: "mod+alt+Backspace",
keys: "X",
label: "Remove the current split",
run: () => removeSplit(),
},
...TX_TYPES.map((type, index) => ({
group: "Editor",
input: true,
keys: `alt+${index + 1}`,
keys: `${index + 1}`,
label: `Make it a ${type}`,
run: () => setType(type),
})),
...FIELDS.map(([keys, field, label]) => ({
group: "Editor",
input: true,
keys,
label: `Go to ${label}`,
run: () => focusField(field),
})),
{
group: "Editor",
input: true,
keys: "alt+i",
keys: "T",
label: "Go to the split transaction title",
run: () => document.getElementById("group-title")?.focus(),
},
@@ -519,7 +526,11 @@
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4" bind:this={root}>
<div
class="flex flex-col gap-4"
onfocusin={(event) => (lastSplit = splitOf(event.target) ?? lastSplit)}
bind:this={root}
>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div
aria-label="Transaction type"
@@ -539,9 +550,7 @@
type="button"
>
{TYPE_LABEL[type]}
<span class="kbd hidden lg:inline-flex"
>{prettyKeys(`alt+${index + 1}`)[0]}</span
>
<span class="kbd hidden lg:inline-flex">{index + 1}</span>
</button>
{/each}
</div>
@@ -613,7 +622,7 @@
<Field
error={errors.group_title?.[0]}
for="group-title"
keys="alt+i"
keys="T"
label="Description of the split transaction"
>
<input
@@ -626,20 +635,29 @@
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
<div
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-9 items-center gap-2 rounded-cladd-md px-3.5 font-semibold text-[13px] text-cladd-primary disabled:opacity-60 sm:h-cladd-md"
<Button
class="h-9 px-3.5 text-[13px] sm:h-cladd-md"
color="brand"
disabled={saving}
onclick={save}
type="button"
variant="fill"
>
{editing ? "Update transaction" : "Save transaction"}
<span class="kbd hidden sm:inline-flex"
<span
class="kbd hidden bg-white/20! text-current! shadow-none! sm:inline-flex"
>{prettyKeys("mod+Enter")[0]}</span
>
</button>
</Button>
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
<span class="hidden sm:inline">After saving</span>
<select
@@ -685,11 +703,4 @@
</p>
{/if}
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
</div>
@@ -10,18 +10,21 @@
id,
invalid = false,
placeholder = "",
onpick,
}: {
value: Ref;
search: (query: string) => Option[];
id: string;
invalid?: boolean;
placeholder?: string;
onpick?: (option: Option) => void;
} = $props();
</script>
<AutocompleteInput
{id}
{invalid}
{onpick}
openOnFocus
{placeholder}
{search}
+16 -14
View File
@@ -8,7 +8,6 @@
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import AccountField from "./AccountField.svelte";
import type { Catalog } from "./catalog.svelte.js";
@@ -205,9 +204,7 @@
>
<Trash class="size-3.5" />
Remove split
<span class="kbd hidden lg:inline-flex"
>{prettyKeys("mod+alt+Backspace")[0]}</span
>
<span class="kbd hidden lg:inline-flex">X</span>
</button>
{/if}
{/snippet}
@@ -216,7 +213,7 @@
<Field
error={err("description")}
for={id("description")}
keys={keyHint("alt+d")}
keys={keyHint("d")}
label="Description"
>
<AutocompleteInput
@@ -229,10 +226,10 @@
bind:value={split.description}
/>
</Field>
{@render account("source", "Source account", "alt+f")}
{@render account("destination", "Destination account", "alt+t")}
{@render account("source", "Source account", "f")}
{@render account("destination", "Destination account", "t")}
{#if first}
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
<Field error={err("date")} for={id("date")} keys="w" label="Date">
<DateInput
id={id("date")}
invalid={Boolean(err("date"))}
@@ -245,7 +242,7 @@
<Field
error={err("amount")}
for={id("amount")}
keys={keyHint("alt+m")}
keys={keyHint("m")}
label="Amount"
>
<AmountInput
@@ -328,7 +325,7 @@
<Field
error={err("budget_id")}
for={id("budget")}
keys={keyHint("alt+b")}
keys={keyHint("b")}
label="Budget"
>
<RefField
@@ -343,7 +340,7 @@
<Field
error={err("category_name")}
for={id("category")}
keys={keyHint("alt+k")}
keys={keyHint("c")}
label="Category"
>
<AutocompleteInput
@@ -385,7 +382,7 @@
<Field
error={err("tags")}
for={id("tags")}
keys={keyHint("alt+g")}
keys={keyHint("#")}
label="Tags"
>
<TagsInput
@@ -396,7 +393,12 @@
/>
</Field>
{#if withdrawal}
<Field error={err("bill_id")} for={id("bill")} label="Subscription">
<Field
error={err("bill_id")}
for={id("bill")}
keys={keyHint("s")}
label="Subscription"
>
<RefField
id={id("bill")}
invalid={Boolean(err("bill_id"))}
@@ -409,7 +411,7 @@
<Field
error={err("notes")}
for={id("notes")}
keys={keyHint("alt+r")}
keys={keyHint("r")}
label="Notes"
>
<textarea
@@ -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>>(
+8 -3
View File
@@ -364,15 +364,20 @@ function splitPayload(form: Form, split: SplitForm, update: boolean) {
return body;
}
const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
let foreignAmount = update ? "0" : null;
if (foreign) {
foreignAmount = amountString(
split.foreignAmount,
split.foreignCurrency?.decimals ?? 2
);
}
return {
...body,
...accountFields("source", sideOf(form, split, "source")),
...accountFields("destination", sideOf(form, split, "destination")),
amount: amountString(split.amount, split.currency?.decimals ?? 2),
currency_code: split.currency?.code,
foreign_amount: foreign
? amountString(split.foreignAmount, split.foreignCurrency?.decimals ?? 2)
: null,
foreign_amount: foreignAmount,
foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
};
}
+198 -39
View File
@@ -1,8 +1,11 @@
import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
import { session } from "#lib/session.svelte.js";
import type { Catalog } from "./catalog.svelte.js";
import {
type AccountRef,
type Cur,
emptyForm,
KINDS,
OWN_SIDE,
@@ -13,9 +16,9 @@ import {
toAccountRef,
} from "./model.js";
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
const WORD = /\S+/g;
const NUMERIC = /^[\d(]/;
@@ -27,21 +30,109 @@ export interface Segment {
value: string;
}
export interface Equal {
code: string;
text: string;
}
export interface Quick {
amount: number | null;
amountText: string;
currency: string;
description: string;
equal: Equal | null;
segments: Segment[];
type: TxType;
}
export function parseQuick(input: string): Quick {
const segments: Segment[] = [];
const words: string[] = [];
let amount: number | null = null;
let amountText = "";
const enabledCodes = () =>
new Set(
session.currencies
.filter((item) => item.attributes.enabled)
.map((item) => item.attributes.code)
);
interface Parse {
amount: number | null;
amountText: string;
coded: "amount" | "equal" | null;
currency: string;
current: Segment | null;
equal: Equal | null;
segments: Segment[];
words: string[];
}
function takeCode(state: Parse, word: string, codes: Set<string>) {
const { coded } = state;
const code = word.toUpperCase();
state.coded = null;
if (!(coded && codes.has(code))) {
return false;
}
if (coded === "amount") {
state.currency = code;
} else if (state.equal) {
state.equal.code = code;
}
return true;
}
function takeEqual(state: Parse, word: string) {
if (!word.startsWith("=") || state.equal) {
return false;
}
state.equal = { code: "", text: word.slice(1) };
state.coded = "equal";
state.current = null;
return true;
}
function takeMarker(state: Parse, word: string, start: number) {
const { current } = state;
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
state.segments.push(segment);
state.current = MULTI.has(segment.marker) ? segment : null;
return true;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
return true;
}
return false;
}
function takeAmount(state: Parse, word: string) {
const value =
state.amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value === null) {
return false;
}
state.amount = value;
state.amountText = word;
state.coded = "amount";
return true;
}
export function parseQuick(input: string, codes = enabledCodes()): Quick {
const state: Parse = {
amount: null,
amountText: "",
coded: null,
currency: "",
current: null,
equal: null,
segments: [],
words: [],
};
let deposit = false;
let current: Segment | null = null;
let first = true;
for (const match of input.matchAll(WORD)) {
let [word] = match;
@@ -52,42 +143,32 @@ export function parseQuick(input: string): Quick {
start += 1;
}
first = false;
if (!word) {
continue;
const taken =
!word ||
takeCode(state, word, codes) ||
takeEqual(state, word) ||
takeMarker(state, word, start) ||
takeAmount(state, word);
if (!taken) {
state.words.push(word);
}
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
segments.push(segment);
current = MULTI.has(segment.marker) ? segment : null;
continue;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
continue;
}
const value: number | null =
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value !== null) {
amount = value;
amountText = word;
continue;
}
words.push(word);
}
const hasTarget = segments.some((segment) => segment.marker === ">");
const hasTarget = state.segments.some((segment) => segment.marker === ">");
let type: TxType = "withdrawal";
if (hasTarget) {
type = "transfer";
} else if (deposit) {
type = "deposit";
}
return { amount, amountText, description: words.join(" "), segments, type };
return {
amount: state.amount,
amountText: state.amountText,
currency: state.currency,
description: state.words.join(" "),
equal: state.equal,
segments: state.segments,
type,
};
}
export const markerValue = (quick: Quick, marker: Marker) =>
@@ -158,6 +239,77 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
: [`No asset account or liability called "${ownName}"`];
}
function curOf(code: string | undefined): Cur | null {
const found = session.currencies.find(
(item) => item.attributes.code === code
);
return found
? {
code: found.attributes.code,
decimals: found.attributes.decimal_places ?? 2,
symbol: found.attributes.symbol,
}
: null;
}
function resolveMoney(
quick: Quick,
split: SplitForm,
own: AccountRef,
target: AccountRef | null
): string[] {
const native = own.currency?.code;
const { equal } = quick;
split.currency = own.currency;
split.amount = quick.amountText;
const first = quick.currency || native;
if (!native) {
return [];
}
if (!equal) {
return first === native
? []
: [`${own.name} is in ${native}: add =amount ${native}`];
}
const second =
equal.code || (first === native ? target?.currency?.code : native);
if (first === native && second && second !== native) {
split.foreignAmount = equal.text;
split.foreignCurrency = curOf(second);
return [];
}
if (second === native && first !== native) {
split.amount = equal.text;
split.foreignAmount = quick.amountText;
split.foreignCurrency = curOf(first);
return [];
}
const example =
session.currencies.find(
(item) => item.attributes.enabled && item.attributes.code !== native
)?.attributes.code ?? "USD";
return [`Name the other currency, like =${equal.text || "520"} ${example}`];
}
function resolveBill(quick: Quick, catalog: Catalog, split: SplitForm) {
const name = markerValue(quick, "%");
if (!name) {
return [];
}
if (quick.type !== "withdrawal") {
return ["Only a withdrawal can pay a subscription"];
}
const wanted = name.toLowerCase();
const bill = (catalog.bills.data ?? []).find(
(item) => item.attributes.name?.toLowerCase() === wanted
);
if (!bill) {
return [`No subscription called "${name}"`];
}
split.bill = { id: bill.id, name: bill.attributes.name ?? name };
return [];
}
function resolveLinks(
quick: Quick,
catalog: Catalog,
@@ -199,10 +351,17 @@ export function resolveQuick(
split,
counterparty ? recent[counterparty] : null
);
problems.push(...links.problems);
problems.push(
...links.problems,
...resolveBill(quick, catalog, split),
...resolveMoney(
quick,
split,
split[own],
quick.type === "transfer" ? split[other] : null
)
);
split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
split.description =
quick.description ||
split[other].name ||
+14 -17
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Amount from "#lib/components/Amount.svelte";
import Money from "#lib/components/Money.svelte";
import { formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import type { Total } from "./object-groups.js";
let {
@@ -12,24 +13,20 @@
tone?: "income" | "expense" | "plain";
empty?: string;
} = $props();
const merged = $derived(display.total(totals) !== null);
</script>
{#each totals as total (total.code)}
<div>
{#if tone === "plain"}
<span class="num">{formatMoney(total)}</span>
{:else}
<Amount
amount={total.amount}
code={total.code}
decimals={total.decimals}
symbol={total.symbol}
{tone}
/>
{/if}
</div>
{:else}
{#if totals.length === 0}
{#if empty}
<span class="text-cladd-fg-softer">{empty}</span>
{/if}
{/each}
{:else if tone !== "plain"}
<Money parts={totals} {tone} />
{:else if merged}
<div><span class="num">{display.text(totals)}</span></div>
{:else}
{#each totals as total (total.code)}
<div><span class="num">{formatMoney(total)}</span></div>
{/each}
{/if}
+37 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Calendar from "@lucide/svelte/icons/calendar";
import { untrack } from "svelte";
import { tick, untrack } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";
import { addDays, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
@@ -11,16 +11,21 @@
value = $bindable(""),
ref = $bindable(null),
invalid = false,
compact = false,
oncommit,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
invalid?: boolean;
compact?: boolean;
oncommit?: (value: string) => void;
} = $props();
let text = $state(value);
let picker = $state<HTMLInputElement | null>(null);
let focused = $state(false);
const parsed = $derived(parseDate(text, today()));
const shown = $derived(parsed ?? value);
@@ -29,6 +34,9 @@
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
: ""
);
const display = $derived(
compact && !focused && shown ? formatDate(shown) : text
);
$effect(() => {
const next = value;
@@ -47,14 +55,33 @@
}
}
function commit(next: string) {
text = next;
value = next;
oncommit?.(next);
}
function handleFocus(
event: FocusEvent & { currentTarget: HTMLInputElement }
) {
focused = true;
const input = event.currentTarget;
if (compact) {
tick().then(() => input.select());
}
}
function handleBlur() {
focused = false;
if (parsed) {
text = parsed;
value = parsed;
commit(parsed);
}
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Enter" && parsed) {
commit(parsed);
}
if (
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
!(event.altKey || event.ctrlKey || event.metaKey)
@@ -62,8 +89,7 @@
event.preventDefault();
const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
value = addDays(parsed ?? value ?? today(), step);
text = value;
commit(addDays(parsed ?? value ?? today(), step));
}
}
@@ -78,17 +104,20 @@
{...rest}
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
autocomplete="off"
class="{INPUT} num pr-28"
class="{INPUT} num {compact ? "pr-8" : "pr-28"}"
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
onkeydown={handleKey}
spellcheck="false"
value={text}
value={display}
>
{#if !compact}
<span
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
>{label}</span
>
{/if}
<button
aria-label="Pick a date"
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
@@ -101,10 +130,7 @@
<input
aria-hidden="true"
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
onchange={({ currentTarget }) => {
({ value } = currentTarget);
text = value;
}}
onchange={({ currentTarget }) => commit(currentTarget.value)}
tabindex="-1"
type="date"
{value}
+1 -1
View File
@@ -10,5 +10,5 @@ export function setQuery(values: Record<string, string | null | undefined>) {
url.searchParams.delete(key);
}
}
return goto(url, { replace: true, state: page.state });
return goto(url, { replace: true, reset: false, state: page.state });
}
@@ -30,6 +30,7 @@
const action = new Action();
const values = $state<Record<string, string>>({});
const fields: Record<string, HTMLElement> = {};
const links = $derived(
(piggy.attributes.accounts ?? []).map((link) => {
@@ -50,9 +51,10 @@
}
}
function fail(message: string) {
function fail(message: string, id = open[0]?.id ?? "") {
action.failed = true;
action.message = message;
fields[id]?.focus();
}
async function save() {
@@ -64,11 +66,11 @@
}
const amount = evalAmount(raw);
if (amount === null || amount <= 0) {
return fail("Enter an amount above zero");
return fail("Enter an amount above zero", link.id);
}
if (amount > link.max + 1e-9) {
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
return fail(`${link.name} allows at most ${max}`);
return fail(`${link.name} allows at most ${max}`, link.id);
}
deltas[link.id] = mode === "add" ? amount : -amount;
}
@@ -109,6 +111,7 @@
onkeydown={onEscape}
bind:value={values[link.id]}
{@attach (node) => {
fields[link.id] = node;
if (i === 0) {
node.focus();
}
@@ -116,7 +119,11 @@
/>
<span class="text-[12px] text-cladd-fg-soft">
up to
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
<Amount
{...piggyMoney(piggy.attributes, link.max)}
exact
tone="neutral"
/>
</span>
</div>
{:else}
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Item, TransactionGroup } from "#lib/api/types.js";
import Place from "./Place.svelte";
import SameDays from "./SameDays.svelte";
import { loadPlace, placeOf, visitOf, visitsIn } from "./usual.js";
const OWN = {
deposit: "destination",
transfer: "source",
withdrawal: "source",
} as const;
const TONE: Record<string, string> = {
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
};
let { group }: { group: Item<TransactionGroup> } = $props();
const splits = $derived(group.attributes.transactions);
const first = $derived(splits[0]);
const day = $derived(first.date.slice(0, 10));
const currency = $derived({
code: first.currency_code,
decimals: first.currency_decimal_places,
symbol: first.currency_symbol,
});
const currencies = $derived(
[
currency,
{
code: first.foreign_currency_code,
decimals: first.foreign_currency_decimal_places,
symbol: first.foreign_currency_symbol,
},
].filter((option) => option.code)
);
const place = $derived(placeOf(splits));
const own = $derived.by(() => {
const side = OWN[first.type as keyof typeof OWN];
const id = side ? first[`${side}_id`] : null;
return side && id ? { id, name: first[`${side}_name`] ?? "" } : null;
});
const history = createQuery(() => ({
enabled: place !== null,
queryFn: () =>
place ? loadPlace(place, group.id, day) : Promise.resolve([]),
queryKey: ["receipt", "place", group.id, place?.id, day],
}));
const best = $derived.by(() => {
if (!place) {
return null;
}
const options = currencies.flatMap((option) => {
const current = visitOf(group, place, option.code ?? "");
return current
? [
{
currency: option,
current,
past: visitsIn(history.data ?? [], place, option.code ?? ""),
},
]
: [];
});
return options.reduce<(typeof options)[number] | null>(
(top, option) =>
top && top.past.length >= option.past.length ? top : option,
null
);
});
</script>
{#if best || own}
<div class="grid items-stretch gap-3 lg:grid-cols-2">
{#if place && best}
<Place
currency={best.currency}
current={best.current}
loading={history.isPending}
past={best.past}
{place}
tone={TONE[first.type] ?? "text-cladd-fg"}
/>
{/if}
{#if own}
<SameDays account={own} {currency} {day} groupId={group.id} />
{/if}
</div>
{/if}
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import Paperclip from "@lucide/svelte/icons/paperclip";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, lists } from "#lib/api/lists.js";
import type {
Attachment,
Item,
LinkType,
One,
Page,
PiggyBankEvent,
Split,
TransactionGroup,
TransactionLink,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { formatNumber } from "#lib/format.js";
import {
journalId,
type Signed,
signedAmount,
} from "#lib/transactions/split.js";
interface Linked {
amount: Signed | null;
description: string;
group: string | null;
id: string;
notes: string;
phrase: string;
}
type Event = Item<PiggyBankEvent> & {
links?: Record<string, { uri?: string }>;
};
const PIGGY_URI = /piggy-banks\/(\d+)/;
const BYTES_KB = 1024;
let { id, splits }: { id: string; splits: Split[] } = $props();
const journals = $derived(splits.map(journalId));
const attachments = createQuery(() => ({
queryFn: () =>
api<Page<Attachment>>(`/v1/transactions/${id}/attachments`, {
query: { limit: 500 },
}),
queryKey: ["transaction-attachments", id],
}));
const events = createQuery(() => ({
queryFn: () =>
api<Page<PiggyBankEvent>>(`/v1/transactions/${id}/piggy-bank-events`, {
query: { limit: 500 },
}),
queryKey: ["transaction-piggy-events", id],
}));
const piggies = createQuery(() => ({
...lists.piggyBanks(),
enabled: (events.data?.data.length ?? 0) > 0,
}));
async function loadLinks(ids: string[]): Promise<Linked[]> {
const [types, ...pages] = await Promise.all([
all<LinkType>("/v1/link-types"),
...ids.map((journal) =>
api<Page<TransactionLink>>(`/v1/transaction-journals/${journal}/links`)
),
]);
const found = await Promise.all(
ids.flatMap((journal, index) =>
pages[index].data.map(async (link) => {
const { inward_id, outward_id, link_type_id, notes } =
link.attributes;
const source = inward_id === journal;
const other = source ? outward_id : inward_id;
const linkType = types.find((t) => t.id === link_type_id);
const group = await api<One<TransactionGroup>>(
`/v1/transaction-journals/${other}`
).catch(() => null);
const split = group?.data.attributes.transactions.find(
(s) => journalId(s) === other
);
return {
amount: split ? signedAmount(split) : null,
description: split?.description ?? `#${other}`,
group: group?.data.id ?? null,
id: link.id,
notes: notes ?? "",
phrase:
(source
? linkType?.attributes.outward
: linkType?.attributes.inward) ?? "linked to",
};
})
)
);
return [...new Map(found.map((link) => [link.id, link])).values()];
}
const links = createQuery(() => ({
enabled: journals.length > 0,
queryFn: () => loadLinks(journals),
queryKey: ["transaction-links", id, journals.join(",")],
}));
async function removeLink(linkId: string) {
await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" });
await links.refetch();
}
function piggyOf(event: Event) {
const uri = Object.values(event.links ?? {}).find((l) => l?.uri)?.uri;
const piggyId = uri?.match(PIGGY_URI)?.[1];
const name = (piggies.data ?? []).find((p) => p.id === piggyId)?.attributes
.name;
return { id: piggyId, name: name ?? (piggyId ? `#${piggyId}` : "") };
}
const files = $derived(attachments.data?.data ?? []);
const piggyEvents = $derived((events.data?.data ?? []) as Event[]);
const linked = $derived(links.data ?? []);
const count = $derived(
[files, piggyEvents, linked].filter((list) => list.length > 0).length
);
</script>
{#if count > 0}
<div class="grid items-stretch gap-3 {count > 1 ? "lg:grid-cols-2" : ""}">
{#if linked.length}
<Box body="p-0" title="Transaction links">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each linked as link (link.id)}
<li class="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5">
<span class="text-cladd-fg-soft">{link.phrase}</span>
{#if link.group}
<a
class="text-brand hover:underline"
href="/transactions/show/{link.group}"
>{link.description}</a
>
{:else}
<span>{link.description}</span>
{/if}
{#if link.amount}
<Amount
amount={link.amount.amount}
code={link.amount.code}
decimals={link.amount.decimals}
symbol={link.amount.symbol}
tone={link.amount.tone}
/>
{/if}
{#if link.notes}
<span class="text-cladd-fg-soft">({link.notes})</span>
{/if}
<ConfirmButton
class="ml-auto"
confirmLabel="Press again to remove"
onconfirm={() => removeLink(link.id)}
>
Remove link
</ConfirmButton>
</li>
{/each}
</ul>
</Box>
{/if}
{#if files.length}
<Box body="p-0" title="Attachments">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each files as file (file.id)}
<li class="flex flex-col gap-0.5 px-3 py-1.5">
<span class="flex items-center gap-2">
<Paperclip class="size-3.5 text-cladd-fg-soft" />
<a
class="min-w-0 truncate text-brand hover:underline"
data-sveltekit-reload
href="/attachments/view/{file.id}"
>{file.attributes.title || file.attributes.filename}</a
>
<span class="num text-cladd-fg-soft"
>{formatNumber((file.attributes.size ?? 0) / BYTES_KB, 1)}
KB</span
>
<a
class="ml-auto text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
data-sveltekit-reload
href="/attachments/download/{file.id}"
>Download</a
>
</span>
{#if file.attributes.notes}
<span class="whitespace-pre-line text-[12px] text-cladd-fg-soft"
>{file.attributes.notes}</span
>
{/if}
</li>
{/each}
</ul>
</Box>
{/if}
{#if piggyEvents.length}
<Box body="p-0" title="Piggy banks">
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
{#each piggyEvents as piggyEvent (piggyEvent.id)}
{@const piggy = piggyOf(piggyEvent)}
<li class="flex items-center justify-between gap-3 px-3 py-1.5">
{#if piggy.id}
<a
class="text-brand hover:underline"
href="/piggy-banks/show/{piggy.id}"
>{piggy.name}</a
>
{:else}
<span>Piggy bank</span>
{/if}
<Amount
amount={piggyEvent.attributes.amount}
code={piggyEvent.attributes.currency_code}
decimals={piggyEvent.attributes.currency_decimal_places}
symbol={piggyEvent.attributes.currency_symbol}
/>
</li>
{/each}
</ul>
</Box>
{/if}
</div>
{/if}
@@ -0,0 +1,382 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import type { Split } from "#lib/api/types.js";
import AccountField from "#lib/components/editor/AccountField.svelte";
import type { Catalog } from "#lib/components/editor/catalog.svelte.js";
import {
type AccountRef,
CASH,
emptyRef,
FREE_KIND,
KINDS,
OWN_SIDE,
type Ref,
SHARED,
type Side,
type TxType,
} from "#lib/components/editor/model.js";
import RefField from "#lib/components/editor/RefField.svelte";
import AmountInput from "#lib/components/inputs/AmountInput.svelte";
import AutocompleteInput from "#lib/components/inputs/AutocompleteInput.svelte";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import { amountString, evalAmount } from "#lib/components/inputs/parse.js";
import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import type { Patch } from "#lib/transactions/mass.js";
import { FIELD_LABEL, type FieldName, type Row } from "./fields.js";
type Outcome = { patch: Patch; all: boolean } | { problem: string } | null;
let {
row,
split,
type,
catalog,
onsave,
oncancel,
}: {
row: Row;
split: Split;
type: TxType;
catalog: Catalog;
onsave: (patch: Patch, all: boolean) => Promise<string | null>;
oncancel: () => void;
} = $props();
const field = $derived(row.field);
const side = $derived<Side>(
row.field === "destination" ? "destination" : "source"
);
const decimals = (value: number | null | undefined) => value ?? 2;
const fixed = (value: string | null | undefined, places: number) =>
value ? Number(value).toFixed(places) : "";
function initialText() {
if (row.field === "category") {
return split.category_name ?? "";
}
return (row.field === "notes" ? split.notes : split.description) ?? "";
}
function initialAccount(): AccountRef {
const id = split[`${side}_id`] ?? null;
const kind = split[`${side}_type`] ?? "";
return kind === CASH
? { currency: null, id: null, kind: CASH, name: "" }
: { currency: null, id, kind, name: split[`${side}_name`] ?? "" };
}
function initialRef(): Ref {
const id = row.field === "bill" ? split.bill_id : split.budget_id;
const name = row.field === "bill" ? split.bill_name : split.budget_name;
return id ? { id, name: name ?? "" } : emptyRef();
}
let root = $state<HTMLElement | null>(null);
const start = untrack(() => ({
account: initialAccount(),
amount: fixed(split.amount, decimals(split.currency_decimal_places)),
date: split.date.slice(0, 10),
foreign: fixed(
split.foreign_amount,
decimals(split.foreign_currency_decimal_places)
),
ref: initialRef(),
tags: [...(split.tags ?? [])],
text: initialText(),
}));
let text = $state(start.text);
let date = $state(start.date);
let amount = $state(start.amount);
let foreign = $state(start.foreign);
let account = $state<AccountRef>(start.account);
let ref = $state<Ref>(start.ref);
let tags = $state<string[]>(start.tags);
let saving = $state(false);
let problem = $state("");
const positive = (value: string) => (evalAmount(value) ?? 0) > 0;
function amountOutcome(): Outcome {
if (!positive(amount)) {
return { problem: "The amount must be a positive number" };
}
const patch: Patch = {
amount: amountString(amount, decimals(split.currency_decimal_places)),
};
const code = split.foreign_currency_code;
if (code && !foreign.trim() && type === "transfer") {
return { problem: `Enter the amount in ${code}` };
}
if (code && foreign.trim() && !positive(foreign)) {
return { problem: "The foreign amount must be a positive number" };
}
if (code) {
patch.foreign_amount = foreign.trim()
? amountString(foreign, decimals(split.foreign_currency_decimal_places))
: "0";
}
const same =
Number(patch.amount) === Number(split.amount) &&
(!code || Number(patch.foreign_amount) === Number(split.foreign_amount));
return same ? null : { all: false, patch };
}
function accountOutcome(): Outcome {
const name = account.name.trim();
const all = SHARED[type][side];
const other = split[`${side === "source" ? "destination" : "source"}_id`];
if (!(account.id || (name && FREE_KIND[type][side]))) {
return { problem: `Choose the ${FIELD_LABEL[row.field]}` };
}
if (account.id && !KINDS[type][side].includes(account.kind)) {
return {
problem: `A ${account.kind.toLowerCase()} can't be the ${side} of a ${type}`,
};
}
if (account.id && account.id === other) {
return { problem: "Source and destination are the same account" };
}
const want =
side === OWN_SIDE[type]
? split.currency_code
: type === "transfer" &&
(split.foreign_currency_code ?? split.currency_code);
if (want && account.currency && account.currency.code !== want) {
return {
problem: `${account.name} is in ${account.currency.code}, this amount is in ${want}. Press e to change both`,
};
}
if (account.id === split[`${side}_id`]) {
return null;
}
return {
all,
patch: account.id
? { [`${side}_id`]: account.id }
: { [`${side}_name`]: name },
};
}
function textOutcome(
key: "description" | "notes" | "category_name",
current: string | null | undefined
): Outcome {
const value = text.trim() || null;
return value === (current || null)
? null
: { all: false, patch: { [key]: value } };
}
function dateOutcome(): Outcome {
if (!date) {
return { problem: "Enter a date like today, -1, 14.10 or 2026-10-14" };
}
return date === split.date.slice(0, 10)
? null
: { all: true, patch: { date: `${date}T${split.date.slice(11, 19)}` } };
}
function refOutcome(key: "bill_id" | "budget_id"): Outcome {
if (!ref.id && ref.name.trim()) {
return { problem: `Pick an existing ${FIELD_LABEL[row.field]}` };
}
return (ref.id ?? null) === (split[key] ?? null)
? null
: { all: false, patch: { [key]: ref.id } };
}
const OUTCOMES: Record<FieldName, () => Outcome> = {
amount: amountOutcome,
bill: () => refOutcome("bill_id"),
budget: () => refOutcome("budget_id"),
category: () => textOutcome("category_name", split.category_name),
date: dateOutcome,
description: () =>
text.trim()
? textOutcome("description", split.description)
: { problem: "Describe the transaction" },
destination: accountOutcome,
notes: () => textOutcome("notes", split.notes),
source: accountOutcome,
tags: () =>
JSON.stringify(tags) === JSON.stringify(split.tags ?? [])
? null
: { all: false, patch: { tags } },
};
async function submit() {
if (saving) {
return;
}
const result = OUTCOMES[row.field]();
if (result === null) {
oncancel();
return;
}
if ("problem" in result) {
({ problem } = result);
return;
}
saving = true;
problem = (await onsave(result.patch, result.all)) ?? "";
saving = false;
}
function pickSaves() {
queueMicrotask(() => {
if (root?.contains(document.activeElement)) {
submit();
}
});
}
onMount(async () => {
await tick();
const input = root?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"input:not([type=date]):not([tabindex='-1']), textarea"
);
input?.focus();
if (row.field !== "notes") {
input?.select();
}
});
useKeys(() => [
{
group: "Field",
input: true,
keys: "Escape",
label: "Cancel the field",
run: oncancel,
},
{
group: "Field",
input: true,
keys: "mod+Enter",
label: "Save the field",
run: submit,
},
]);
const id = $derived(`receipt-${row.key.replace(":", "-")}`);
const BUTTON =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2 font-semibold text-[12px] text-cladd-fg disabled:opacity-60";
</script>
<form
aria-label="Edit {FIELD_LABEL[field]}"
class="min-w-0 flex-wrap items-center gap-x-2 gap-y-1 {field === "notes" ||
field === "description"
? "flex w-full"
: "inline-flex align-middle"}"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
bind:this={root}
>
{#if field === "description"}
<input
aria-label="Description"
class="{INPUT} max-w-2xl flex-1 sm:text-[15px]"
{id}
bind:value={text}
>
{:else if field === "notes"}
<textarea
aria-label="Notes"
class="{INPUT} h-auto max-h-[60vh] min-h-24 w-full py-1.5 leading-relaxed [field-sizing:content]"
{id}
bind:value={text}
></textarea>
{:else if field === "category"}
<AutocompleteInput
aria-label="Category"
class="w-56"
free
{id}
onpick={pickSaves}
openOnFocus
placeholder="(no category)"
search={catalog.categoryOptions}
bind:value={text}
/>
{:else if field === "budget" || field === "bill"}
<div class="w-56">
<RefField
{id}
onpick={pickSaves}
placeholder={field === "bill" ? "(no subscription)" : "(no budget)"}
search={field === "bill" ? catalog.billOptions : catalog.budgetOptions}
bind:value={ref}
/>
</div>
{:else if field === "tags"}
<div class="w-72">
<TagsInput
{id}
placeholder="Comma or Enter between tags"
search={(query, taken) => catalog.tagOptions(query, taken)}
bind:tags
/>
</div>
{:else if field === "date"}
<DateInput
aria-label="Date"
class="w-56"
{id}
placeholder="today, -1, 14.10"
bind:value={date}
/>
{:else if field === "amount"}
<AmountInput
aria-label="Amount"
class="w-40"
currency={split.currency_code ?? ""}
decimals={decimals(split.currency_decimal_places)}
{id}
bind:value={amount}
/>
{#if split.foreign_currency_code}
<AmountInput
aria-label="Amount in {split.foreign_currency_code}"
class="w-36"
currency={split.foreign_currency_code}
decimals={decimals(split.foreign_currency_decimal_places)}
bind:value={foreign}
/>
{/if}
{:else}
<div class="w-72">
<AccountField
{catalog}
free={Boolean(FREE_KIND[type][side])}
{id}
kinds={KINDS[type][side]}
onpick={pickSaves}
placeholder={FREE_KIND[type][side] ? "a new name" : ""}
bind:value={account}
/>
</div>
{/if}
<span class="flex items-center gap-1.5">
<button class={BUTTON} disabled={saving} type="submit">
Save
<span class="kbd hidden lg:inline-flex"
>{field === "notes" ? prettyKeys("mod+Enter")[0] : "↵"}</span
>
</button>
<button class={BUTTON} onclick={oncancel} type="button">
Cancel <span class="kbd hidden lg:inline-flex">Esc</span>
</button>
</span>
{#if saving || problem}
<span
class="text-[12px] {problem ? "text-expense" : "text-cladd-fg-soft"}"
role="status"
>{saving ? "Saving" : problem}</span
>
{/if}
</form>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { blocks, type Inline } from "./notes.js";
let { text }: { text: string } = $props();
const parsed = $derived(blocks(text));
</script>
{#snippet line(
parts: Inline[]
)}
{#each parts as part, index (index)}
{#if part.kind === "link"}
<a
class="break-all text-brand hover:underline"
href={part.href}
rel="noopener noreferrer nofollow"
target="_blank"
>{part.text}</a
>
{:else if part.kind === "bold"}
<strong class="font-semibold">{part.text}</strong>
{:else if part.kind === "code"}
<code class="rounded-[4px] bg-cladd-surface-next px-1 text-[0.92em]"
>{part.text}</code
>
{:else}
{part.text}
{/if}
{/each}
{/snippet}
<div class="flex max-w-[92ch] flex-col gap-2 break-words">
{#each parsed as block, index (index)}
{#if block.kind === "head"}
<p class="font-semibold">{@render line(block.line)}</p>
{:else if block.kind === "list"}
<svelte:element
class="flex flex-col gap-0.5 pl-5 {block.ordered
? "list-decimal"
: "list-disc"}"
this={block.ordered ? "ol" : "ul"}
>
{#each block.items as item, i (i)}
<li>{@render line(item)}</li>
{/each}
</svelte:element>
{:else}
<p>
{#each block.lines as row, i (i)}
{#if i > 0}
<br>
{/if}
{@render line(row)}
{/each}
</p>
{/if}
{/each}
</div>
+187
View File
@@ -0,0 +1,187 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import { parse } from "#lib/dates.js";
import { formatDate, type Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import {
cadence,
type Place,
usualOf,
type Visit,
verdict,
} from "./usual.js";
const HEIGHT = 44;
const R_MAX = 15;
const R_MIN = 3;
const SPREAD = 2.4;
const RECENT = 6;
let {
place,
current,
past,
currency,
tone,
loading = false,
}: {
place: Place;
current: Visit;
past: Visit[];
currency: Omit<Money, "amount">;
tone: string;
loading?: boolean;
} = $props();
let width = $state(0);
let active = $state<Visit | null>(null);
const visits = $derived(
[current, ...past].sort((a, b) => a.date.localeCompare(b.date))
);
const usual = $derived(usualOf(current, past));
const said = $derived(usual ? verdict(usual.change) : null);
const span = $derived.by(() => {
const times = visits.map((visit) => parse(visit.date).getTime());
return { from: Math.min(...times), to: Math.max(...times) };
});
const biggest = $derived(Math.max(...visits.map((visit) => visit.amount), 1));
const largest = $derived(
Math.max(R_MIN + 3, Math.min(R_MAX, width / (visits.length * SPREAD)))
);
const recent = $derived(past.slice(0, RECENT));
const money = (amount: number) => display.text([{ ...currency, amount }]);
function x(visit: Visit) {
const room = Math.max(width - 2 * largest, 0);
const length = span.to - span.from;
const at = length ? (parse(visit.date).getTime() - span.from) / length : 1;
return largest + at * room;
}
const radius = (visit: Visit) =>
R_MIN + (largest - R_MIN) * Math.sqrt(visit.amount / biggest);
function stroke(visit: Visit) {
if (visit === current) {
return 0;
}
return active === visit ? 2 : 1.25;
}
const label = (visit: Visit) =>
`${formatDate(visit.date)}, ${visit.description}, ${money(visit.amount)}`;
</script>
<Box
body="flex flex-col gap-2 pt-2.5"
href="/accounts/show/{place.id}"
title="{place.from ? "Same route" : "At this place"}{past.length
? `, last ${past.length}`
: ""}"
>
{#if loading}
<p class="px-3 pb-2.5 text-cladd-fg-soft">Loading visits...</p>
{:else if !past.length}
<p class="px-3 pb-2.5 text-cladd-fg-soft">
{place.from
? "First transfer on this route."
: `First time at ${place.name}.`}
</p>
{:else}
<p class="flex flex-wrap items-baseline gap-x-1.5 px-3">
{#if usual}
<span class="text-cladd-fg-soft">usual</span>
<span class="num font-medium">{money(usual.median)}</span>
{#if usual.every !== null}
<span class="text-cladd-fg-softer">·</span>
<span>{cadence(usual.every)}</span>
{/if}
{#if said}
<span class="text-cladd-fg-softer">·</span>
<span class={said.warn ? "text-warn" : "text-cladd-fg-soft"}
>this one {said.text}</span
>
{/if}
{:else}
<span class="text-cladd-fg-soft"
>{past.length === 1 ? "One visit" : `${past.length} visits`}
before this one</span
>
{/if}
</p>
<div class="px-3">
<div bind:clientWidth={width}>
{#if width}
<svg
aria-label="Visits by date, dot size is the amount"
class="block overflow-visible"
height={HEIGHT}
role="img"
{width}
>
<line
class="stroke-current text-cladd-fg-softest"
x1={largest}
x2={width - largest}
y1={HEIGHT / 2}
y2={HEIGHT / 2}
/>
{#each visits as visit (visit.group)}
{@const self = visit === current}
<a
aria-label={label(visit)}
href="/transactions/show/{visit.group}"
onblur={() => (active = null)}
onfocus={() => (active = visit)}
onpointerenter={() => (active = visit)}
onpointerleave={() => (active = null)}
>
<circle
class={self
? `fill-current ${tone}`
: `fill-cladd-surface stroke-current ${active === visit ? "text-cladd-fg" : "text-cladd-fg-soft"}`}
cx={x(visit)}
cy={HEIGHT / 2}
r={radius(visit)}
stroke-width={stroke(visit)}
/>
</a>
{/each}
</svg>
{/if}
</div>
</div>
<p
class="num flex items-baseline justify-between gap-3 px-3 text-[12px] text-cladd-fg-soft"
>
<span>{formatDate(visits[0].date)}</span>
<span>{formatDate(visits.at(-1)?.date)}</span>
</p>
<table class="mb-1 w-full border-cladd-outline border-t">
<tbody>
{#each recent as visit (visit.group)}
<tr
class="h-7 {active === visit ? "bg-row-hover" : ""}"
onpointerenter={() => (active = visit)}
onpointerleave={() => (active = null)}
>
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
{formatDate(visit.date, "short")}
</td>
<td class="w-full max-w-0 truncate px-2">
<a class="hover:underline" href="/transactions/show/{visit.group}"
>{visit.description}</a
>
</td>
<td class="num px-3 text-right whitespace-nowrap">
{money(visit.amount)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { Split, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, type Money } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
const DAYS = 2;
const AROUND = 5;
let {
account,
currency,
day,
groupId,
}: {
account: { id: string; name: string };
currency: Omit<Money, "amount">;
day: string;
groupId: string;
} = $props();
const start = $derived(addDays(day, -DAYS));
const end = $derived(addDays(day, DAYS));
const query = createQuery(() => ({
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${account.id}/transactions`, {
end,
start,
}),
queryKey: ["receipt", "same-days", account.id, start, end],
}));
function amountOn(split: Split) {
const signed = signedAmount(split, account.id);
const foreign =
split.foreign_currency_code === currency.code &&
split.currency_code !== currency.code;
if (!foreign) {
return signed;
}
const sign = Math.sign(signed.amount) || 1;
return {
...currency,
amount: sign * Number(split.foreign_amount),
tone: toneOf(split, sign < 0 ? -1 : 1),
};
}
const rows = $derived(
(query.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter(
(split) =>
split.source_id === account.id ||
split.destination_id === account.id
)
.map((split) => ({
balance:
split.source_id === account.id
? split.source_balance_after
: split.destination_balance_after,
date: split.date.slice(0, 10),
description: split.description,
group: group.id,
key: journalId(split),
money: amountOn(split),
self: group.id === groupId,
}))
)
.reverse()
);
const shown = $derived.by(() => {
const first = rows.findIndex((row) => row.self);
const last = rows.findLastIndex((row) => row.self);
return first < 0
? rows.slice(0, 2 * AROUND + 1)
: rows.slice(Math.max(0, first - AROUND), last + AROUND + 1);
});
</script>
{#snippet more()}
<a
class="text-brand hover:underline"
href="/accounts/show/{account.id}/{start}/{end}"
>All {rows.length} on {account.name} from {formatDate(start, "short")} to
{formatDate(end, "short")}</a
>
{/snippet}
<Box
body="p-0"
footer={shown.length < rows.length ? more : undefined}
href="/accounts/show/{account.id}/{start}/{end}"
title="Same days on {account.name}"
>
{#if query.isPending}
<p class="px-3 py-2.5 text-cladd-fg-soft">Loading {account.name}...</p>
{:else if query.error}
<p class="px-3 py-2.5 text-expense">{query.error.message}</p>
{:else}
<table class="w-full">
<thead>
<tr
class="h-8 border-cladd-outline border-b text-left font-medium text-[12px] text-cladd-fg-soft"
>
<th class="px-3 font-medium">Date</th>
<th class="px-2 font-medium">Description</th>
<th class="px-2 text-right font-medium">Amount</th>
<th class="px-3 text-right font-medium whitespace-nowrap">
Balance after
</th>
</tr>
</thead>
<tbody>
{#each shown as row (row.key)}
<tr
class="h-7 {row.self
? "bg-row-selected font-medium"
: "hover:bg-row-hover"}"
>
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
{formatDate(row.date, "short")}
</td>
<td class="w-full max-w-0 truncate px-2">
{#if row.self}
{row.description}
{:else}
<a class="hover:underline" href="/transactions/show/{row.group}"
>{row.description}</a
>
{/if}
</td>
<td class="px-2 text-right whitespace-nowrap">
<Amount
amount={row.money.amount}
code={row.money.code}
decimals={row.money.decimals}
symbol={row.money.symbol}
tone={row.money.tone}
/>
</td>
<td class="num px-3 text-right whitespace-nowrap">
{row.balance === null || row.balance === undefined
? ""
: display.text([{ ...currency, amount: row.balance }])}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+76
View File
@@ -0,0 +1,76 @@
import type { Split } from "#lib/api/types.js";
import {
isEditableType,
SHARED,
type Side,
} from "#lib/components/editor/model.js";
export type FieldName =
| "date"
| "description"
| "amount"
| "source"
| "destination"
| "category"
| "budget"
| "tags"
| "bill"
| "notes";
export interface Row {
field: FieldName;
index: number;
key: string;
}
export const FIELD_LABEL: Record<FieldName, string> = {
amount: "amount",
bill: "subscription",
budget: "budget",
category: "category",
date: "date",
description: "description",
destination: "destination account",
notes: "notes",
source: "source account",
tags: "tags",
};
const SIDES: Side[] = ["source", "destination"];
export const rowKey = (field: FieldName, index: number) => `${index}:${field}`;
export function rowsOf(splits: Split[]): Row[] {
const type = splits[0]?.type ?? "";
if (!isEditableType(type)) {
return [];
}
const many = splits.length > 1;
const withdrawal = type === "withdrawal";
const locked = splits.some((split) => split.reconciled);
const shared = SIDES.filter((side) => SHARED[type][side]);
const top: FieldName[] = many
? ["date", ...(locked ? [] : shared)]
: [
"date",
"description",
...(locked ? [] : (["amount", ...SIDES] as FieldName[])),
];
const own = (split: Split): FieldName[] => [
...(many ? (["description"] as const) : []),
...(many && !split.reconciled
? ["amount" as const, ...SIDES.filter((side) => !SHARED[type][side])]
: []),
"category",
...(withdrawal ? (["budget"] as const) : []),
"tags",
...(withdrawal ? (["bill"] as const) : []),
"notes",
];
return [
...top.map((field) => ({ field, index: 0 })),
...splits.flatMap((split, index) =>
own(split).map((field) => ({ field, index }))
),
].map((row) => ({ ...row, key: rowKey(row.field, row.index) }));
}
+92
View File
@@ -0,0 +1,92 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type {
Bill,
Item,
Page,
Split,
TransactionGroup,
} from "#lib/api/types.js";
import { addDays, addMonths } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { journalId } from "#lib/transactions/split.js";
const CENT = 0.005;
export interface Suggestion {
bill: Item<Bill>;
journal: string;
month: string;
paid: string | null;
}
export const openSplits = (splits: Split[]) =>
splits.filter(
(split) =>
split.type === "withdrawal" && !split.bill_id && split.destination_id
);
function priceIn(split: Split, code: string): number | null {
if (split.currency_code === code) {
return Math.abs(toNumber(split.amount));
}
return split.foreign_currency_code === code && split.foreign_amount
? Math.abs(toNumber(split.foreign_amount))
: null;
}
function fits(bill: Bill, split: Split): boolean {
const price = priceIn(split, bill.currency_code ?? "");
return (
bill.active === true &&
price !== null &&
price >= toNumber(bill.amount_min) - CENT &&
price <= toNumber(bill.amount_max) + CENT &&
(bill.date ?? "").slice(0, 10) <= split.date.slice(0, 10)
);
}
const width = (bill: Item<Bill>) =>
toNumber(bill.attributes.amount_max) - toNumber(bill.attributes.amount_min);
async function lastDestination(billId: string): Promise<string | null> {
const page = await api<Page<TransactionGroup>>(
`/v1/bills/${billId}/transactions`,
{ query: { limit: 1 } }
);
return page.data[0]?.attributes.transactions[0]?.destination_id ?? null;
}
export async function suggest(splits: Split[]): Promise<Suggestion[]> {
const open = openSplits(splits);
if (!open.length) {
return [];
}
const month = `${open[0].date.slice(0, 7)}-01`;
const bills = await all<Bill>("/v1/bills", {
end: addDays(addMonths(month, 1), -1),
start: month,
});
const candidates = open.map((split) => ({
bills: bills.filter((bill) => fits(bill.attributes, split)),
split,
}));
const ids = [
...new Set(candidates.flatMap((c) => c.bills.map((bill) => bill.id))),
];
const last = new Map(
await Promise.all(
ids.map(async (id) => [id, await lastDestination(id)] as const)
)
);
return candidates.flatMap(({ bills: options, split }) => {
const [bill] = options
.filter((option) => last.get(option.id) === split.destination_id)
.sort((a, b) => width(a) - width(b));
if (!bill) {
return [];
}
const paid = bill.attributes.paid_dates?.[0]?.date?.slice(0, 10) ?? null;
return [{ bill, journal: journalId(split), month, paid }];
});
}
+67
View File
@@ -0,0 +1,67 @@
export type Inline =
| { kind: "text" | "bold" | "code"; text: string }
| { kind: "link"; text: string; href: string };
export type Block =
| { kind: "head"; line: Inline[] }
| { kind: "list"; ordered: boolean; items: Inline[][] }
| { kind: "para"; lines: Inline[][] };
const TOKEN =
/\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"])/g;
const BULLET = /^\s*(?:[-*•]|(\d+)[.)])\s+(.*)$/;
const HEAD = /^#{1,6}\s+(.*)$/;
const LINES = /\r?\n/;
export function inline(text: string): Inline[] {
const out: Inline[] = [];
let last = 0;
for (const match of text.matchAll(TOKEN)) {
const [whole, bold, code, label, href, url] = match;
if (match.index > last) {
out.push({ kind: "text", text: text.slice(last, match.index) });
}
if (bold) {
out.push({ kind: "bold", text: bold });
} else if (code) {
out.push({ kind: "code", text: code });
} else {
out.push({ href: href ?? url, kind: "link", text: label ?? url });
}
last = match.index + whole.length;
}
if (last < text.length) {
out.push({ kind: "text", text: text.slice(last) });
}
return out;
}
export function blocks(notes: string): Block[] {
const result: Block[] = [];
let open: Block | null = null;
for (const raw of notes.split(LINES)) {
const line = raw.trimEnd();
const head = line.match(HEAD);
const bullet = line.match(BULLET);
if (!line.trim()) {
open = null;
} else if (head) {
result.push({ kind: "head", line: inline(head[1]) });
open = null;
} else if (bullet) {
const ordered = Boolean(bullet[1]);
if (open?.kind !== "list" || open.ordered !== ordered) {
open = { items: [], kind: "list", ordered };
result.push(open);
}
open.items.push(inline(bullet[2]));
} else {
if (open?.kind !== "para") {
open = { kind: "para", lines: [] };
result.push(open);
}
open.lines.push(inline(line));
}
}
return result;
}
+171
View File
@@ -0,0 +1,171 @@
import { api } from "#lib/api/client.js";
import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
import { addMonths, daysBetween } from "#lib/dates.js";
import { isCash } from "#lib/transactions/split.js";
const COUNT = 12;
const FETCH = 25;
const MIN_COUNT = 3;
const LOOKBACK_MONTHS = 60;
const PERCENT = 100;
const ABOUT = 5;
const NOTABLE = 15;
type Side = "source" | "destination";
export interface Place {
from: string | null;
id: string;
name: string;
side: Side;
type: string;
}
export interface Visit {
amount: number;
date: string;
description: string;
group: string;
}
export interface Usual {
change: number;
every: number | null;
median: number;
}
const COUNTER: Record<string, Side> = {
deposit: "source",
transfer: "destination",
withdrawal: "destination",
};
const CADENCE: [number, number, string][] = [
[0, 1.5, "daily"],
[5, 9, "weekly"],
[12, 17, "every two weeks"],
[26, 35, "monthly"],
[80, 100, "quarterly"],
[170, 195, "every half year"],
[345, 385, "yearly"],
];
export function placeOf(splits: Split[]): Place | null {
const [first] = splits;
const side = first ? COUNTER[first.type] : undefined;
const id = side ? first[`${side}_id`] : null;
if (!(side && id) || isCash(first[`${side}_type`])) {
return null;
}
return {
from: first.type === "transfer" ? first.source_id : null,
id,
name: first[`${side}_name`] ?? "",
side,
type: first.type,
};
}
function priceIn(split: Split, code: string): number | null {
if (split.currency_code === code) {
return Math.abs(Number(split.amount));
}
return split.foreign_currency_code === code && split.foreign_amount
? Math.abs(Number(split.foreign_amount))
: null;
}
export function visitOf(
group: Item<TransactionGroup>,
place: Place,
code: string
): Visit | null {
const splits = group.attributes.transactions.filter(
(split) =>
split[`${place.side}_id`] === place.id &&
(!place.from || split.source_id === place.from)
);
const prices = splits.map((split) => priceIn(split, code));
if (!splits.length || prices.includes(null)) {
return null;
}
return {
amount: prices.reduce<number>((sum, price) => sum + (price ?? 0), 0),
date: splits[0].date.slice(0, 10),
description:
group.attributes.group_title || splits[0].description || `#${group.id}`,
group: group.id,
};
}
export async function loadPlace(
place: Place,
groupId: string,
end: string
): Promise<Item<TransactionGroup>[]> {
const page = place.from
? await api<Page<TransactionGroup>>("/v1/search/transactions", {
query: {
limit: FETCH,
query: `type:transfer source_account_id:${place.from} destination_account_id:${place.id} date_before:${end}`,
},
})
: await api<Page<TransactionGroup>>(
`/v1/accounts/${place.id}/transactions`,
{
query: {
end,
limit: FETCH,
start: addMonths(end, -LOOKBACK_MONTHS),
type: place.type,
},
}
);
return page.data.filter((group) => group.id !== groupId);
}
export const visitsIn = (
groups: Item<TransactionGroup>[],
place: Place,
code: string
) =>
groups
.map((group) => visitOf(group, place, code))
.filter((visit): visit is Visit => visit !== null)
.slice(0, COUNT);
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function usualOf(current: Visit, past: Visit[]): Usual | null {
if (past.length < MIN_COUNT) {
return null;
}
const typical = median(past.map((visit) => visit.amount));
const dates = [current, ...past].map((visit) => visit.date).sort();
const gaps = dates.slice(1).map((date, i) => daysBetween(dates[i], date));
return {
change: typical ? Math.round((current.amount / typical - 1) * PERCENT) : 0,
every: gaps.length ? median(gaps) : null,
median: typical,
};
}
export function cadence(days: number): string {
return (
CADENCE.find(([low, high]) => days >= low && days <= high)?.[2] ??
`every ${Math.round(days)} days`
);
}
export function verdict(change: number) {
if (Math.abs(change) < ABOUT) {
return { text: "about usual", warn: false };
}
return change > 0
? { text: `${change}% above usual`, warn: change >= NOTABLE }
: { text: `${-change}% below usual`, warn: false };
}
@@ -1,7 +1,9 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { displayCode, inDisplay } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
displayMoney,
drillQuery,
money,
navigate,
@@ -34,12 +36,15 @@
.sort((a, b) => a.name.localeCompare(b.name));
});
const sums = $derived.by(() => {
const into = displayCode(new Set(rows.map((row) => row.code)));
const map = new Map<string, { start: number; end: number }>();
for (const row of rows) {
const sum = map.get(row.code) ?? { end: 0, start: 0 };
sum.start += row.start;
sum.end += row.end;
map.set(row.code, sum);
const start = inDisplay(row.start, row.code, into);
const end = inDisplay(row.end, row.code, into);
const sum = map.get(start.code) ?? { end: 0, start: 0 };
sum.start += start.amount;
sum.end += end.amount;
map.set(start.code, sum);
}
const order = report.codes;
return [...map.entries()].sort(
@@ -73,17 +78,17 @@
title="Account balances"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<table class="w-full min-w-[34rem] border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Name</th>
<th class="h-7 px-3 text-right font-medium">
<th class="h-7 w-full px-3 text-left font-medium">Name</th>
<th class="h-7 w-32 px-3 text-right font-medium">
Balance at start of period
</th>
<th class="h-7 px-3 text-right font-medium">
<th class="h-7 w-32 px-3 text-right font-medium">
Balance at end of period
</th>
<th class="h-7 px-3 text-right font-medium">Difference</th>
<th class="h-7 w-32 px-3 text-right font-medium">Difference</th>
</tr>
</thead>
<tbody>
@@ -95,7 +100,7 @@
? "bg-row-selected"
: ""}"
>
<td class="max-w-[16rem] px-3">
<td class="max-w-0 px-3">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
@@ -106,10 +111,14 @@
{row.name}
</button>
</td>
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
<td class="num px-3 text-right">
{displayMoney(row.code, row.start)}
</td>
<td class="num px-3 text-right">
{displayMoney(row.code, row.end)}
</td>
<td class="num px-3 text-right {tone(row.end - row.start)}">
{money(row.code, row.end - row.start, true)}
{displayMoney(row.code, row.end - row.start, true)}
</td>
</tr>
{/each}
@@ -1,246 +0,0 @@
<script lang="ts">
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
import ArrowRight from "@lucide/svelte/icons/arrow-right";
import Calculator from "@lucide/svelte/icons/calculator";
import Star from "@lucide/svelte/icons/star";
import { createQuery } from "@tanstack/svelte-query";
import type { Account } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, formatDateTime } from "#lib/format.js";
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
let {
id,
account,
before,
after,
columns,
}: {
id: string;
account: Account | undefined;
before: number | undefined;
after: number | undefined;
columns: AuditColumn[];
} = $props();
const report = useReport();
const p = $derived(report.params);
const code = $derived(account?.currency_code ?? "");
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
const rows = $derived(
query.data && before !== undefined
? ledger(query.data, id, code, before)
: undefined
);
const show = (key: AuditColumn) => columns.includes(key);
const ICONS = {
deposit: ArrowRight,
"opening balance": Star,
reconciliation: Calculator,
transfer: ArrowLeftRight,
withdrawal: ArrowLeft,
} as const;
const META_DATES = [
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
] as const;
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
</script>
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
{#if !rows}
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="px-3 py-3">
No activity was recorded on account
<a
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
href="/accounts/show/{id}"
>{account?.name}</a
>
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
</p>
{:else}
<p class="px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(addDays(p.start, -1), "long")}
was:
<span class="num font-semibold">{money(code, before ?? 0)}</span>
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-left text-[12px] text-cladd-fg-soft">
{#if show("icon")}
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
{/if}
{#if show("description")}
<th class="h-7 px-3 font-medium">Description</th>
{/if}
{#if show("balance_before")}
<th class="h-7 px-3 text-right font-medium">Balance before</th>
{/if}
{#if show("amount")}
<th class="h-7 px-3 text-right font-medium">Amount</th>
{/if}
{#if show("balance_after")}
<th class="h-7 px-3 text-right font-medium">Balance after</th>
{/if}
{#if show("date")}
<th class="h-7 px-3 font-medium">Date</th>
{/if}
{#if show("from")}
<th class="h-7 px-3 font-medium">From</th>
{/if}
{#if show("to")}
<th class="h-7 px-3 font-medium">To</th>
{/if}
{#if show("budget")}
<th class="h-7 px-3 font-medium">Budget</th>
{/if}
{#if show("category")}
<th class="h-7 px-3 font-medium">Category</th>
{/if}
{#if show("bill")}
<th class="h-7 px-3 font-medium">Subscription</th>
{/if}
{#if show("created_at")}
<th class="h-7 px-3 font-medium">Created at</th>
{/if}
{#if show("updated_at")}
<th class="h-7 px-3 font-medium">Updated at</th>
{/if}
{#if show("notes")}
<th class="h-7 px-3 font-medium">Notes</th>
{/if}
{#each dateColumns as key (key)}
<th class="h-7 px-3 font-medium">
{key.replace("_date", "").replace("process", "processing")}
date
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.split.transaction_journal_id)}
{@const s = row.split}
{@const Icon =
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
<tr
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
>
{#if show("icon")}
<td class="px-2 py-1.5 text-cladd-fg-soft">
<Icon aria-label={s.type} class="size-3.5" />
</td>
{/if}
{#if show("description")}
<td class="max-w-[22rem] px-3 py-1">
<a
class="hover:underline"
href="/transactions/show/{row.group}"
>
{#if row.title}
{row.title}
({s.description})
{:else}
{s.description}
{/if}
</a>
</td>
{/if}
{#if show("balance_before")}
<td
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
>
{money(code, row.before)}
</td>
{/if}
{#if show("amount")}
<td
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
0
? "text-expense"
: "text-income"}"
>
{money(code, row.amount)}
</td>
{/if}
{#if show("balance_after")}
<td class="num whitespace-nowrap px-3 py-1 text-right">
{money(code, row.after)}
</td>
{/if}
{#if show("date")}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s.date, "medium")}
</td>
{/if}
{#if show("from")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a class="hover:underline" href="/accounts/show/{s.source_id}"
>{s.source_name}</a
>
</td>
{/if}
{#if show("to")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a
class="hover:underline"
href="/accounts/show/{s.destination_id}"
>{s.destination_name}</a
>
</td>
{/if}
{#if show("budget")}
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
{/if}
{#if show("category")}
<td class="px-3 py-1">{s.category_name ?? ""}</td>
{/if}
{#if show("bill")}
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
{/if}
{#if show("created_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.created)}
</td>
{/if}
{#if show("updated_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.updated)}
</td>
{/if}
{#if show("notes")}
<td
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
>
{s.notes ?? ""}
</td>
{/if}
{#each dateColumns as key (key)}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s[key] ?? null, "medium")}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(p.end, "long")}
was:
<span class="num font-semibold">{money(code, after ?? 0)}</span>
</p>
{/if}
</Box>
@@ -1,73 +0,0 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import Box from "#lib/components/Box.svelte";
import {
AUDIT_COLUMNS,
type AuditColumn,
DEFAULT_AUDIT,
} from "#lib/reports/audit.js";
import { reportQueries } from "#lib/reports/queries.js";
import { navigate, useReport } from "#lib/reports/report.svelte.js";
import { page } from "$app/state";
import AuditAccount from "./AuditAccount.svelte";
const report = useReport();
const p = $derived(report.params);
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
const columns = $derived.by(() => {
const raw = page.url.searchParams.get("cols");
if (raw === null) {
return DEFAULT_AUDIT;
}
const keys = new Set(raw.split(","));
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
});
const accounts = $derived(
report.selected
.map((id) => ({ account: report.byId().get(id), id }))
.filter((a) => a.account)
.sort((a, b) =>
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
)
);
function toggle(key: AuditColumn, on: boolean) {
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
k === key ? on : columns.includes(k)
);
navigate({ cols: next.join(",") });
}
</script>
<div class="flex flex-col gap-4">
<Box body="px-3 py-2" title="Options">
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
<legend class="sr-only">Visible columns</legend>
{#each AUDIT_COLUMNS as column (column.key)}
<label class="flex h-7 items-center gap-1.5 text-[13px]">
<input
checked={columns.includes(column.key)}
class="size-3.5 accent-[var(--ff-brand)]"
onchange={(event) =>
toggle(column.key, event.currentTarget.checked)}
type="checkbox"
>
{column.label}
</label>
{/each}
</fieldset>
</Box>
{#if !report.accountsReady}
<p class="text-cladd-fg-soft">Loading</p>
{/if}
{#each accounts as item (item.id)}
<AuditAccount
account={item.account}
after={balances.data?.after.get(item.id)}
before={balances.data?.before.get(item.id)}
{columns}
id={item.id}
/>
{/each}
</div>
@@ -4,6 +4,7 @@
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { displayCode, inDisplay, shownCode } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js";
@@ -31,11 +32,16 @@
const charts = $derived.by(() => {
const byCode = new Map<string, LineSeries[]>();
for (const set of chart.data ?? []) {
const code = set.currency_code ?? "";
const sets = chart.data ?? [];
const into = displayCode(
new Set(sets.map((set) => set.currency_code ?? ""))
);
for (const set of sets) {
const from = set.currency_code ?? "";
const code = shownCode(from, into);
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
x: x.slice(0, 10),
y: Number(y),
y: inDisplay(Number(y), from, into).amount,
}));
const list = byCode.get(code) ?? [];
if (net && list.length > 0) {
+461
View File
@@ -0,0 +1,461 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import Lanes from "#lib/components/charts/Lanes.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import {
buildBoard,
cursorOf,
grew,
longMonth,
mergedLane,
monthFacts,
monthOf,
monthRange,
shiftMonth,
spendingLanes,
} from "#lib/reports/board.js";
import { displayCode, displayFlows } from "#lib/reports/display.js";
import { exchanges } from "#lib/reports/flows.js";
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
import {
createReport,
type Drill,
drillQuery,
navigate,
provideReport,
} from "#lib/reports/report.svelte.js";
import { page } from "$app/state";
import BoardTables from "./BoardTables.svelte";
import CurrencyTable from "./CurrencyTable.svelte";
import Headline from "./Headline.svelte";
import MoneyFlow from "./MoneyFlow.svelte";
import MonthPanel from "./MonthPanel.svelte";
import NetWorthLanes from "./NetWorthLanes.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import WhatGrew from "./WhatGrew.svelte";
const now = today();
const thisYear = now.slice(0, 4);
const thisMonth = monthOf(now);
const query = $derived(page.url.searchParams);
const view = $derived(query.get("view") === "month" ? "month" : "year");
const tables = $derived(query.has("tables"));
const merged = $derived(query.has("merged"));
const tab = $derived(query.get("tab") === "month" ? "month" : "grew");
const cursor = $derived(cursorOf(period.range, now));
const year = $derived(cursor.slice(0, 4));
const anchor = $derived(year === thisYear ? now : `${year}-12-31`);
const span = $derived(ledgerWindow(anchor));
const first = $derived(monthOf(period.first ?? "0000-01"));
const ledger = createQuery(() => ledgerQuery(anchor));
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const own = $derived(
assets.data && liabilities.data
? [...assets.data, ...liabilities.data]
: undefined
);
const ownIds = $derived(own?.map((a) => a.id) ?? []);
const range = $derived(
view === "month"
? monthRange(cursor)
: { end: `${year}-12-31`, start: `${year}-01-01` }
);
const params = $derived<ReportParams>({
accounts: ownIds,
end: range.end,
items: [],
start: range.start,
type: "default",
});
const mine = $derived(
ledger.data && own ? ownFlows(ledger.data, own) : undefined
);
const into = $derived(
mine ? displayCode(new Set(mine.map((flow) => flow.currency))) : null
);
const report = createReport(
() => params,
() => ledger.data,
() => into
);
provideReport(report);
const flows = $derived(mine && displayFlows(mine, into));
const board = $derived(
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
);
const pcOk = $derived(
!!flows &&
flows.length > 0 &&
flows.every((flow) => flow.pc !== null) &&
(board?.codes.length ?? 0) > 1
);
const lanes = $derived.by(() => {
if (!board) {
return [];
}
return pcOk && merged ? [mergedLane(board)] : spendingLanes(board);
});
const swaps = $derived.by(() => {
if (!(ledger.data && own)) {
return [];
}
const currency = new Map(
own.map((a) => [a.id, a.attributes.currency_code])
);
return exchanges(
ledger.data,
new Set(ownIds),
(id) => currency.get(id) ?? undefined
);
});
const grewRows = $derived(board ? grew(board, cursor) : []);
const facts = $derived(board ? monthFacts(board, cursor) : []);
const loading = $derived(!board);
let hover = $state<string | null>(null);
function go(month: string, drill?: Drill | null) {
const target = monthRange(month);
navigate({
end: target.end,
start: target.start,
...(drill === undefined ? {} : drillQuery(drill)),
});
}
function clamp(month: string) {
if (month > thisMonth) {
return thisMonth;
}
return month < first ? first : month;
}
function move(delta: number) {
const next = clamp(shiftMonth(cursor, delta));
if (next !== cursor) {
go(next);
}
}
function labelFor(month: string, item = "Spent") {
return view === "year" ? item : `${item}, ${longMonth(month)}`;
}
function drillFor(
month: string,
box: string,
extra: Partial<Drill> = {}
): Drill {
return {
at: view === "year" ? month : undefined,
box,
dir: -1,
...extra,
label: labelFor(month, extra.label),
};
}
function openMonth(month: string, segment?: string) {
const found = board?.catKeys.find(
(k) => k.key === segment && k.key !== "other"
);
const drill = drillFor(month, "months", {
dim: found ? "category" : undefined,
key: found?.key,
label: found?.label,
});
const same =
report.drill?.box === "months" &&
report.drill.key === drill.key &&
month === cursor;
go(month, same ? null : drill);
}
function setView(next: "year" | "month") {
navigate({ ...drillQuery(null), view: next === "month" ? "month" : null });
}
function flag(name: string, on: boolean) {
navigate({ [name]: on ? "1" : null });
}
const yearLinks = $derived.by(() => {
const dates = `${ymd(range.start)}/${ymd(range.end)}`;
const top = [...(board?.spending ?? [])]
.filter((flow) => flow.other.type === "Expense account")
.reduce((counts, flow) => {
counts.set(flow.other.id, (counts.get(flow.other.id) ?? 0) + 1);
return counts;
}, new Map<string, number>());
const busiest = [...top.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([id]) => id);
return [
{
href: `/reports/budget/${ALL_ASSETS}/allBudgets/${dates}`,
label: "Budget report",
},
{
href: `/reports/category/${ALL_ASSETS}/allCategories/${dates}`,
label: "Category report",
},
{
href: `/reports/tag/${ALL_ASSETS}/allTags/${dates}`,
label: "Tag report",
},
...(busiest.length > 0
? [
{
href: `/reports/double/${ALL_ASSETS}/${busiest.join(",")}/${dates}`,
label: "Expense and revenue account report",
},
]
: []),
{
href: `/reports/audit/${ALL_ASSETS}/${dates}`,
label: "Transaction history",
},
{
href: `/reports/default/${ALL_ASSETS}/${dates}`,
label: "Default financial report",
},
];
});
useKeys(() => [
{
group: "Report",
keys: "ArrowLeft",
label: "Previous month",
run: () => move(-1),
},
{
group: "Report",
keys: "ArrowRight",
label: "Next month",
run: () => move(1),
},
{
group: "Report",
keys: "[",
label: "Previous year",
run: () => move(-12),
},
{ group: "Report", keys: "]", label: "Next year", run: () => move(12) },
{
group: "Report",
keys: "Enter",
label: "Transactions of the month",
run: () => openMonth(cursor),
},
{
group: "Report",
keys: "m",
label: tables ? "Hide tables by month" : "Show tables by month",
run: () => flag("tables", !tables),
},
{
group: "Report",
keys: "g M",
label: "Month view",
run: () => setView("month"),
},
{
group: "Report",
keys: "g Y",
label: "Year view",
run: () => setView("year"),
},
...(pcOk
? [
{
group: "Report",
keys: "c",
label: merged ? "One lane per currency" : "All in one currency",
run: () => flag("merged", !merged),
},
]
: []),
]);
</script>
<PageHeader
crumbs={[{ label: "Reports" }]}
subtitle={view === "month" ? longMonth(cursor) : year}
title="Reports"
>
{#snippet actions()}
<nav aria-label="Report period" class="flex items-center gap-3 text-[13px]">
{#if `${Number(year) - 1}-12` >= first}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(-12)}
title="Previous year ["
type="button"
>
‹ {Number(year) - 1}
</button>
{/if}
{#if year < thisYear}
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => move(12)}
title="Next year ]"
type="button"
>
{Number(year) + 1}
›
</button>
{/if}
<span aria-hidden="true" class="text-cladd-fg-softest">|</span>
{#each [
{ key: "year" as const, label: "Year", shortcut: "g Y" },
{ key: "month" as const, label: "Month", shortcut: "g M" },
] as item (item.key)}
<button
aria-pressed={view === item.key}
class={view === item.key
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => setView(item.key)}
title="{item.label} view {item.shortcut}"
type="button"
>
{item.label}
</button>
{/each}
</nav>
{/snippet}
</PageHeader>
{#if ledger.error}
<p class="mb-4 text-expense">
Firefly III did not return the transactions: {ledger.error.message}
</p>
{/if}
<div class="flex flex-col gap-4">
<Headline {board} month={view === "month" ? cursor : null} />
<ReportBox
body="px-3 pt-2 pb-3"
empty={lanes.length === 0}
emptyText="No spending or income in {year}."
id="months"
{loading}
title={merged && pcOk
? "Spent each month, all in one currency"
: "Spent each month"}
>
{#snippet tools()}
{#if pcOk}
<button
aria-pressed={merged}
class="text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("merged", !merged)}
title="Switch c"
type="button"
>
{merged ? "One lane per currency" : "All in one currency"}
</button>
{/if}
{/snippet}
<Lanes
{cursor}
dotLabel="earned"
label="Spent each month by currency"
{lanes}
onselect={(month, _lane, segment) => openMonth(month, segment)}
partial={board?.partial}
pinned={view === "month"}
title={(row) => longMonth(row.key)}
bind:hover
/>
</ReportBox>
<div class="flex gap-4 text-[13px] lg:hidden" role="tablist">
{#each [
{ key: "grew", label: "What grew" },
{ key: "month", label: longMonth(cursor) },
] as item (item.key)}
<button
aria-selected={tab === item.key}
class={tab === item.key
? "font-semibold text-cladd-fg"
: "text-brand dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ tab: item.key === "month" ? "month" : null })}
role="tab"
type="button"
>
{item.label}
</button>
{/each}
</div>
<ReportRow class="lg:grid-cols-2">
<WhatGrew
class={tab === "grew" ? "" : "max-lg:hidden"}
codes={board?.codes.length ?? 0}
colors={board?.catKeys ?? []}
{cursor}
{loading}
monthDrill={(extra) => drillFor(cursor, "grew", extra)}
partial={cursor === board?.partial}
rows={grewRows}
/>
<MonthPanel
class={tab === "month" ? "" : "max-lg:hidden"}
{facts}
{loading}
month={cursor}
monthDrill={(extra) => drillFor(cursor, "month", extra)}
partial={cursor === board?.partial}
swaps={swaps.filter((swap) => monthOf(swap.date) === cursor)}
totals={view === "year"}
/>
</ReportRow>
{#if view === "month"}
<MoneyFlow switcher />
{:else if board}
<CurrencyTable {board} {swaps} />
<NetWorthLanes {cursor} groups={ledger.data ?? []} {own} {year} />
{/if}
{#if tables}
<BoardTables {view} />
{/if}
<nav
aria-label="More reports"
class="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[12px] text-cladd-fg-soft"
>
<button
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
onclick={() => flag("tables", !tables)}
title="Tables by month m"
type="button"
>
{tables ? "Hide tables by month" : "Tables by month"}
</button>
{#each yearLinks as link (link.href)}
<a
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
href={link.href}
>{link.label}</a
>
{/each}
</nav>
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
import { bucketLabel } from "#lib/reports/view.js";
import BudgetLimits from "./BudgetLimits.svelte";
import CurrencySwitch from "./CurrencySwitch.svelte";
import Matrix from "./Matrix.svelte";
import ReportBox from "./ReportBox.svelte";
import ReportRow from "./ReportRow.svelte";
import SpendingCalendar from "./SpendingCalendar.svelte";
import Subscriptions from "./Subscriptions.svelte";
import SumTable from "./SumTable.svelte";
let { view }: { view: "year" | "month" } = $props();
const report = useReport();
const flows = $derived(report.flows);
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
const deposits = $derived(income.filter((f) => f.type === "deposit"));
const periodCols = $derived(
buckets(report.params.start, report.params.end, "month").map((key) => ({
key,
label: bucketLabel(key, "month", true),
title: bucketLabel(key, "month"),
}))
);
const month = (date: string) => bucketOf(date, "month");
const NAMES = { budget: "Budget", category: "Category", tag: "Tag" };
</script>
{#snippet matrix(
id: string,
title: string,
dim: "budget" | "category" | "tag",
dir: 1 | -1,
list: typeof expenses
)}
<ReportBox
empty={list.length === 0}
{id}
loading={!flows}
title="{title} in {modeLabel(report.mode)}"
>
{#snippet tools()}
<CurrencySwitch />
{/snippet}
<Matrix
average={dim === "budget"}
box={id}
cols={periodCols}
{dim}
{dir}
nameLabel={NAMES[dim]}
rows={group(list, keys[dim], (f) => month(f.date))}
side={dir === 1 ? "in" : "out"}
/>
</ReportBox>
{/snippet}
<div class="flex flex-col gap-4">
{#if view === "year"}
{@render matrix("budget-periods", "Budgets", "budget", -1, withdrawals)}
{@render matrix(
"category-expenses",
"Categories, spent",
"category",
-1,
withdrawals
)}
{@render matrix(
"category-income",
"Categories, earned",
"category",
1,
deposits
)}
{@render matrix("tag-periods", "Tags, spent", "tag", -1, expenses)}
{:else}
<ReportRow class="lg:grid-cols-2">
<ReportBox
empty={expenses.length === 0}
id="expenses"
loading={!flows}
title="Expenses"
>
<SumTable
box="expenses"
columns={[{ kind: "out", label: "Total" }]}
counts
dim="other"
dir={-1}
footer={total(expenses)}
limit={10}
rows={group(expenses, keys.other)}
sort="out"
/>
</ReportBox>
<ReportBox
empty={income.length === 0}
id="income"
loading={!flows}
title="Revenue / income"
>
<SumTable
box="income"
columns={[{ kind: "in", label: "Total" }]}
counts
dim="other"
dir={1}
footer={total(income)}
limit={10}
rows={group(income, keys.other)}
sort="in"
/>
</ReportBox>
</ReportRow>
<ReportRow class="lg:grid-cols-12">
<BudgetLimits class="lg:col-span-7" />
<ReportBox
class="lg:col-span-5"
empty={expenses.length === 0}
id="tags"
loading={!flows}
title="Tags"
>
<SumTable
box="tags"
columns={[
{ kind: "out", label: "Spent" },
{ kind: "in", label: "Earned" },
]}
dim="tag"
footer={report.totals}
limit={10}
nameLabel="Tag"
rows={group(flows ?? [], keys.tag)}
/>
</ReportBox>
</ReportRow>
<Subscriptions />
{/if}
<SpendingCalendar />
</div>
+26 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange, percent } from "#lib/format.js";
import { inDisplay } from "#lib/reports/display.js";
import { NONE } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js";
import {
@@ -36,12 +37,31 @@
const withdrawals = flows.filter(
(f) => f.dir === -1 && f.type === "withdrawal"
);
const result: Line[] = limits.data.map((item) => {
const merged = new Map<string, Line>();
for (const item of limits.data) {
const a = item.attributes;
const start = (a.start ?? p.start).slice(0, 10);
const end = (a.end ?? p.end).slice(0, 10);
const code = a.currency_code ?? "";
const spent = withdrawals
const { amount, code } = inDisplay(
Number(a.amount ?? 0),
a.currency_code ?? "",
report.into
);
const key = [a.budget_id, start, end, code].join("|");
const found = merged.get(key);
if (found) {
found.budgeted = (found.budgeted ?? 0) + amount;
continue;
}
merged.set(key, {
budgeted: amount,
budgetId: String(a.budget_id),
code,
end,
key: item.id,
name:
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
spent: withdrawals
.filter(
(f) =>
f.budget?.id === String(a.budget_id) &&
@@ -49,19 +69,11 @@
f.date >= start &&
f.date <= end
)
.reduce((sum, f) => sum + f.amount, 0);
return {
budgeted: Number(a.amount ?? 0),
budgetId: String(a.budget_id),
code,
end,
key: item.id,
name:
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
spent,
.reduce((sum, f) => sum + f.amount, 0),
start,
};
});
}
const result = [...merged.values()];
result.sort(
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
);
@@ -0,0 +1,26 @@
<script lang="ts">
import {
modeLabel,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
const report = useReport();
</script>
{#if report.available.length > 1}
<div class="flex items-center gap-3 text-[12px]">
{#each report.available as mode (mode)}
<button
aria-pressed={report.mode === mode}
class={report.mode === mode
? "font-semibold text-cladd-fg"
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
onclick={() => navigate({ cur: mode })}
type="button"
>
{modeLabel(mode)}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,199 @@
<script lang="ts">
import { formatNumber } from "#lib/format.js";
import {
type Board,
longMonth,
monthOf,
shortMonth,
} from "#lib/reports/board.js";
import type { Exchange } from "#lib/reports/flows.js";
import {
drillQuery,
navigate,
useReport,
} from "#lib/reports/report.svelte.js";
import { typical } from "#lib/reports/typical.js";
import ReportBox from "./ReportBox.svelte";
let { board, swaps }: { board: Board; swaps: Exchange[] } = $props();
const report = useReport();
type Kind = "spent" | "earned" | "moved";
const LABELS: Record<Kind, string> = {
earned: "Earned",
moved: "Exchanged",
spent: "Spent",
};
const moved = $derived.by(() => {
const sums = new Map<string, Map<string, number>>();
const add = (code: string, month: string, value: number) => {
const byMonth = sums.get(code) ?? new Map<string, number>();
byMonth.set(month, (byMonth.get(month) ?? 0) + value);
sums.set(code, byMonth);
};
for (const swap of swaps) {
add(swap.to, monthOf(swap.date), swap.toAmount);
add(swap.from, monthOf(swap.date), -swap.fromAmount);
}
return sums;
});
const sources = $derived({
earned: board.earned,
moved,
spent: board.spent,
});
const rows = $derived(
board.codes.flatMap((code) =>
(["spent", "earned", "moved"] as Kind[])
.map((kind) => {
const byMonth = sources[kind].get(code);
return {
byMonth,
code,
kind,
total: board.months.reduce(
(sum, m) => sum + (byMonth?.get(m) ?? 0),
0
),
typical:
kind === "moved" ? null : typical(byMonth, board.typicalMonths),
};
})
.filter((row) => board.months.some((m) => row.byMonth?.get(m)))
)
);
const toneOf = (kind: Kind, value: number) => {
if (kind === "spent") {
return "text-expense";
}
if (kind === "earned") {
return "text-income";
}
return value < 0 ? "text-cladd-fg-soft" : "text-transfer";
};
const cell = (kind: Kind, value: number) =>
kind === "moved" ? formatNumber(value, 0, true) : formatNumber(value, 0);
function open(code: string, kind: Kind, month: string) {
const dir = kind === "spent" ? -1 : 1;
const current = report.drill;
const same =
current?.box === "currencies" &&
current.at === month &&
current.currency === code &&
current.dir === dir;
navigate(
drillQuery(
same
? null
: {
at: month,
box: "currencies",
currency: code,
dir,
label: `${LABELS[kind]} in ${code}, ${longMonth(month)}`,
}
)
);
}
</script>
<ReportBox
empty={rows.length === 0}
id="currencies"
loading={false}
title="By currency"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
></th>
{#each board.months as month (month)}
<th
class="h-7 px-2 text-right font-medium {month === board.partial
? "italic"
: ""}"
title={longMonth(month)}
>
{shortMonth(month)}
</th>
{/each}
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
Typical
</th>
<th class="h-7 px-3 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{#each rows as row, i (`${row.code}-${row.kind}`)}
{@const first = i === 0 || rows[i - 1].code !== row.code}
<tr
class="h-7 hover:bg-row-hover {first
? "border-cladd-outline border-t-2"
: "border-cladd-outline border-t"}"
>
<th
class="sticky left-0 z-[1] whitespace-nowrap bg-cladd-surface px-3 text-left font-normal"
scope="row"
>
<span class="inline-block w-10 font-semibold"
>{first ? row.code : ""}</span
>
<span class="text-cladd-fg-soft">{LABELS[row.kind]}</span>
</th>
{#each board.months as month (month)}
{@const value = row.byMonth?.get(month) ?? 0}
{@const active =
report.drill?.box === "currencies" &&
report.drill.at === month &&
report.drill.currency === row.code &&
report.drill.dir === (row.kind === "spent" ? -1 : 1)}
<td class="p-0 text-right">
{#if value && row.kind !== "moved"}
<button
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {toneOf(
row.kind,
value
)} {active ? "bg-row-selected font-semibold" : ""}"
onclick={() => open(row.code, row.kind, month)}
tabindex="-1"
title="{LABELS[row.kind]} in {row.code}, {longMonth(month)}"
type="button"
>
{cell(row.kind, value)}
</button>
{:else if value}
<span
class="num block whitespace-nowrap px-2 {toneOf(
row.kind,
value
)}"
>{cell(row.kind, value)}</span
>
{/if}
</td>
{/each}
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{row.typical === null ? "" : formatNumber(row.typical, 0)}
</td>
<td class="num whitespace-nowrap px-3 text-right font-semibold">
{cell(row.kind, row.total)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</ReportBox>
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import Box from "#lib/components/Box.svelte";
import {
type Board,
headlines,
longMonth,
ordinal,
shortMonth,
} from "#lib/reports/board.js";
import { money } from "#lib/reports/report.svelte.js";
import { versus, versusText } from "#lib/reports/typical.js";
let {
board,
month = null,
}: { board: Board | undefined; month?: string | null } = $props();
const rows = $derived(
board ? headlines(board, month ? [month] : board.months) : []
);
const year = $derived(board?.months[0]?.slice(0, 4) ?? "");
const period = $derived(month ? longMonth(month) : year);
const showPace = $derived(rows.some((row) => row.pace));
const byDay = $derived(
`Typical by the ${ordinal(Number(board?.now.slice(8, 10) ?? 0))}`
);
const tone = (ratio: number | null) => {
if (ratio === null || Math.abs(ratio) < 0.03) {
return "text-cladd-fg-soft";
}
return ratio > 0 ? "text-expense" : "text-income";
};
</script>
<Box body="px-3 py-2">
{#if !board}
<p class="py-2 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="py-2 text-cladd-fg-soft">No spending or income in {period}.</p>
{:else}
<div class="overflow-x-auto">
<table class="border-collapse text-[13px]">
<thead>
<tr
class="whitespace-nowrap text-left text-[12px] text-cladd-fg-soft"
>
<th class="w-12 pr-3 font-medium"></th>
<th class="pr-10 font-medium">Spent in {period}</th>
{#if showPace && !month}
<th class="pr-10 font-medium">
{shortMonth(board.partial ?? "")}
so far
</th>
{/if}
{#if showPace}
<th class="pr-10 font-medium">{byDay}</th>
{/if}
<th class="pr-10 font-medium">Typical month</th>
<th class="font-medium">Earned in {period}</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.code)}
{@const pace = row.pace}
{@const paceRatio = pace ? versus(pace.sofar, pace.typical) : null}
<tr class="h-9">
<th class="pr-3 text-left font-semibold" scope="row">
{row.code}
</th>
<td class="pr-10 sm:whitespace-nowrap">
<span class="num font-semibold text-[18px] tracking-[-0.01em]"
>{money(
row.code,
month && pace ? pace.sofar : row.spent,
false,
0
)}</span
>
{#if month}
{@const ratio = pace
? paceRatio
: versus(row.spent, row.typical)}
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
ratio
)}"
>{versusText(ratio)}</span
>
{/if}
</td>
{#if showPace && !month}
<td class="pr-10 sm:whitespace-nowrap">
{#if pace}
<span class="num"
>{money(row.code, pace.sofar, false, 0)}</span
>
<span
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
paceRatio
)}"
>{versusText(paceRatio)}</span
>
{/if}
</td>
{/if}
{#if showPace}
<td class="num whitespace-nowrap pr-10">
{#if pace}
{money(row.code, pace.typical, false, 0)}
{/if}
</td>
{/if}
<td class="num whitespace-nowrap pr-10">
{money(row.code, row.typical, false, 0)}
</td>
<td class="num whitespace-nowrap text-income">
{#if row.earned}
{money(row.code, row.earned, false, 0)}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Box>
@@ -1,6 +1,7 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { formatRange } from "#lib/format.js";
import { displayFlows } from "#lib/reports/display.js";
import {
currencies,
measureOf,
@@ -28,10 +29,13 @@
const before = $derived(
earlier.data && report.accountsReady
? total(
displayFlows(
toFlows(
earlier.data,
new Set(report.selected),
(id) => report.byId().get(id)?.currency_code
),
report.into
)
)
: undefined
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more