Compare commits

..
1 Commits
Author SHA1 Message Date
renovate Bot e77d2d6b6f chore(deps): pin fireflyiii/core docker tag
renovate/stability-days Updates have not met minimum release age requirement
2026-10-16 04:03:19 +00:00
188 changed files with 3738 additions and 18895 deletions

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS deps FROM oven/bun:1-alpine AS deps
ENV TERM=xterm-256color ENV TERM=xterm-256color
ENV COLORTERM=truecolor ENV COLORTERM=truecolor
@@ -19,7 +19,7 @@ RUN bun --bun svelte-kit sync
RUN bun run build RUN bun run build
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS runtime FROM oven/bun:1-alpine AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
-6
View File
@@ -8,8 +8,6 @@
"@biomejs/biome": "^2.5.15", "@biomejs/biome": "^2.5.15",
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^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", "@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-static": "^4.0.0", "@sveltejs/adapter-static": "^4.0.0",
@@ -86,10 +84,6 @@
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], "@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=="], "@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=="], "@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] command: [run, serve.ts]
frontend-dev: frontend-dev:
image: oven/bun:alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 image: oven/bun:alpine
profiles: [frontend-dev] profiles: [frontend-dev]
restart: unless-stopped restart: unless-stopped
working_dir: /app working_dir: /app
-2
View File
@@ -21,8 +21,6 @@
"@biomejs/biome": "^2.5.15", "@biomejs/biome": "^2.5.15",
"@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^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", "@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-static": "^4.0.0", "@sveltejs/adapter-static": "^4.0.0",
-271
View File
@@ -1,271 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,310 +0,0 @@
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
@@ -1,240 +0,0 @@
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
@@ -1,28 +0,0 @@
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
@@ -1,194 +0,0 @@
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]));
},
};
}
+3 -27
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { formatMoney, type Money, toNumber } from "#lib/format.js"; 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"; export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
@@ -11,31 +10,10 @@
decimals, decimals,
tone = "auto", tone = "auto",
signed = false, signed = false,
approx = false,
exact = false,
class: className = "", class: className = "",
}: Money & { }: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
tone?: Tone;
signed?: boolean;
approx?: boolean;
exact?: boolean;
class?: string;
} = $props();
const value = $derived(toNumber(amount)); 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(() => { const resolved = $derived.by(() => {
if (tone !== "auto") { if (tone !== "auto") {
return tone; return tone;
@@ -55,8 +33,6 @@
); );
</script> </script>
<span <span class="num {color} {className}"
class="num font-medium {color} {className}" >{formatMoney({ amount, code, decimals, symbol }, signed)}</span
title={converted === null ? undefined : original}
>{approx || converted !== null ? "≈" : ""}{shown}</span
> >
-45
View File
@@ -1,45 +0,0 @@
<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}
+6 -17
View File
@@ -26,7 +26,6 @@
tagsOf, tagsOf,
} from "#lib/components/editor/quick.js"; } from "#lib/components/editor/quick.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte"; 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 { LIST } from "#lib/components/inputs/styles.js";
import { today } from "#lib/dates.js"; import { today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js"; import { formatDate, formatMoney } from "#lib/format.js";
@@ -36,7 +35,7 @@
const SUGGESTIONS = 6; const SUGGESTIONS = 6;
const HINT = const HINT =
"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"; "12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
const catalog = new Catalog(); const catalog = new Catalog();
let input = $state<HTMLInputElement | null>(null); let input = $state<HTMLInputElement | null>(null);
@@ -62,7 +61,6 @@
"!": () => catalog.categoryOptions(query), "!": () => catalog.categoryOptions(query),
"@": () => catalog.accountOptions(KINDS[quick.type][other], query), "@": () => catalog.accountOptions(KINDS[quick.type][other], query),
"#": () => catalog.tagOptions(query, tagsOf(quick)), "#": () => catalog.tagOptions(query, tagsOf(quick)),
"%": () => catalog.billOptions(query),
"^": () => catalog.accountOptions(KINDS[quick.type][own], query), "^": () => catalog.accountOptions(KINDS[quick.type][own], query),
">": () => catalog.accountOptions(KINDS.transfer.destination, query), ">": () => catalog.accountOptions(KINDS.transfer.destination, query),
"~": () => [], "~": () => [],
@@ -109,18 +107,12 @@
function money(form: Form) { function money(form: Form) {
const [split] = form.splits; const [split] = form.splits;
const main = formatMoney({ return formatMoney({
...split.currency, amount: quick.amount ?? 0,
amount: evalAmount(split.amount) ?? 0, code: split.currency?.code,
decimals: split.currency?.decimals,
symbol: split.currency?.symbol,
}); });
if (!split.foreignCurrency) {
return main;
}
const foreign = formatMoney({
...split.foreignCurrency,
amount: evalAmount(split.foreignAmount) ?? 0,
});
return `${main} (${foreign})`;
} }
function syncCaret() { function syncCaret() {
@@ -260,9 +252,6 @@
text: `category ${split.category}${fresh}${last(fromPast.category)}`, text: `category ${split.category}${fresh}${last(fromPast.category)}`,
}); });
} }
if (split.bill.id) {
parts.push({ text: `subscription ${split.bill.name}` });
}
if (split.budget.id) { if (split.budget.id) {
parts.push({ parts.push({
text: `budget ${split.budget.name}${last(fromPast.budget)}`, text: `budget ${split.budget.name}${last(fromPast.budget)}`,
+19 -58
View File
@@ -2,7 +2,6 @@
import ChevronDown from "@lucide/svelte/icons/chevron-down"; import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui"; import { DropdownMenu } from "bits-ui";
import { viewOf } from "#lib/accounts/statement.js";
import { api } from "#lib/api/client.js"; import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js"; import { all, invalidate } from "#lib/api/lists.js";
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js"; import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
@@ -20,22 +19,19 @@
setPrimary, setPrimary,
span, span,
} from "#lib/components/dashboard/data.js"; } from "#lib/components/dashboard/data.js";
import Merchant from "#lib/components/dimension/Merchant.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Pager from "#lib/components/transactions/Pager.svelte"; import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte"; import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.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 DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { contains, daysBetween, today } from "#lib/dates.js"; import { contains, daysBetween, today } from "#lib/dates.js";
import { formatRange } from "#lib/format.js"; import { formatMoney, formatRange } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js"; import { useKeys } from "#lib/keys.svelte.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import Breakdown from "./Breakdown.svelte"; import Breakdown from "./Breakdown.svelte";
import DebtPanel from "./DebtPanel.svelte";
import { import {
canReconcile, canReconcile,
createUrl, createUrl,
@@ -51,7 +47,6 @@
typeOf, typeOf,
} from "./links.js"; } from "./links.js";
import PeriodList from "./PeriodList.svelte"; import PeriodList from "./PeriodList.svelte";
import Statement from "./Statement.svelte";
const SPECIAL = new Set(["initial-balance", "reconciliation"]); const SPECIAL = new Set(["initial-balance", "reconciliation"]);
const BUTTON = const BUTTON =
@@ -74,14 +69,9 @@
const a = $derived(account.data?.data.attributes); const a = $derived(account.data?.data.attributes);
const type = $derived(a ? typeOf(a) : "asset"); const type = $derived(a ? typeOf(a) : "asset");
const item = $derived(account.data?.data); 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(() => ({ const first = createQuery(() => ({
enabled: everything && !merchant, enabled: everything,
queryFn: async () => { queryFn: async () => {
const head = await api<Page<TransactionGroup>>( const head = await api<Page<TransactionGroup>>(
`/v1/accounts/${id}/transactions`, `/v1/accounts/${id}/transactions`,
@@ -120,7 +110,7 @@
return days <= 3000 ? "1M" : "1Y"; return days <= 3000 ? "1M" : "1Y";
}); });
const chart = createQuery(() => ({ const chart = createQuery(() => ({
enabled: !(statement || merchant) && (!everything || first.isSuccess), enabled: !everything || first.isSuccess,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", { api<ChartSet[]>("/v1/chart/account/overview", {
@@ -133,34 +123,25 @@
}), }),
queryKey: ["account-chart", id, range.start, range.end, step], queryKey: ["account-chart", id, range.start, range.end, step],
})); }));
const converted = $derived(!display.active && hasPrimary(chart.data ?? [])); const converted = $derived(hasPrimary(chart.data ?? []));
const cur = $derived(
currency(a?.currency_code ?? session.primary?.code ?? "")
);
const view = $derived(display.active ? viewOf(cur) : null);
const series = $derived( const series = $derived(
(chart.data ?? []).map((set) => { (chart.data ?? []).map((set) => ({
const own = viewOf(currency(set.currency_code));
return {
area: true, area: true,
format: view format: money(converted ? setPrimary(set) : currency(set.currency_code)),
? own.format
: money(converted ? setPrimary(set) : currency(set.currency_code)),
key: set.label, key: set.label,
label: set.label, label: set.label,
points: seriesPoints(set, converted).map((p) => ({ points: seriesPoints(set, converted),
...p, }))
y: p.y * own.rate, );
})), const cur = $derived(
}; currency(a?.currency_code ?? session.primary?.code ?? "")
})
); );
const pageNo = $derived( const pageNo = $derived(
Number(page.url.searchParams.get("page") ?? "1") || 1 Number(page.url.searchParams.get("page") ?? "1") || 1
); );
const dated = createQuery(() => ({ const dated = createQuery(() => ({
enabled: !(everything || statement || merchant), enabled: !everything,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
all<TransactionGroup>( all<TransactionGroup>(
@@ -174,7 +155,7 @@
queryKey: ["account-tx-all", id, period.start, period.end], queryKey: ["account-tx-all", id, period.start, period.end],
})); }));
const paged = createQuery(() => ({ const paged = createQuery(() => ({
enabled: everything && !merchant, enabled: everything,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, { api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
@@ -218,7 +199,7 @@
return TYPE_TITLE[type].replace(" accounts", " account"); return TYPE_TITLE[type].replace(" accounts", " account");
}); });
const balance = $derived( const balance = $derived(
a ? display.text([{ amount: a.current_balance, ...cur }]) : "" a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
); );
function pageHref(n: number) { function pageHref(n: number) {
@@ -319,44 +300,26 @@
<p class="pb-3 text-expense">{failure}</p> <p class="pb-3 text-expense">{failure}</p>
{/if} {/if}
{#if statement}
{#if item}
<Statement {item} />
{/if}
{:else if merchant}
<Merchant {everything} {id} />
{:else}
<div class="flex flex-col gap-3"> <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 <Box
title={a title={a
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}` ? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
: "Chart"} : "Chart"}
> >
<div <div
class="transition-opacity {chart.isPlaceholderData class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
? "opacity-60"
: ""}"
> >
{#if chart.isPending || (everything && first.isPending)} {#if chart.isPending || (everything && first.isPending)}
<div class="h-[300px]"></div> <div class="h-[300px]"></div>
{:else if chart.isError} {:else if chart.isError}
<p class="text-expense">{chart.error.message}</p> <p class="text-expense">{chart.error.message}</p>
{:else if series.length === 0} {:else if series.length === 0}
<p class="py-10 text-center text-cladd-fg-soft"> <p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
No balance to draw.
</p>
{:else} {:else}
<LineChart <LineChart
axisFormat={view?.axis ?? axisFormat={axis(
axis(converted ? setPrimary(chart.data?.[0] as ChartSet) : cur)} converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
)}
height={300} height={300}
label="Balance of {a?.name ?? "the account"}" label="Balance of {a?.name ?? "the account"}"
{marker} {marker}
@@ -366,7 +329,6 @@
{/if} {/if}
</div> </div>
</Box> </Box>
</div>
{#if !everything} {#if !everything}
<div class="grid gap-3 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-3">
@@ -447,4 +409,3 @@
{/if} {/if}
</div> </div>
</div> </div>
{/if}
+68 -169
View File
@@ -13,11 +13,9 @@
import Sparkline from "#lib/components/charts/Sparkline.svelte"; import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { import {
balanceRange, balanceRange,
byCurrency,
type ChartSet, type ChartSet,
seriesPoints, seriesPoints,
} from "#lib/components/dashboard/data.js"; } from "#lib/components/dashboard/data.js";
import Money from "#lib/components/Money.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { import {
ContextMenu, ContextMenu,
@@ -28,9 +26,8 @@
ContextMenuTrigger, ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js"; } from "#lib/components/ui/context-menu/index.js";
import { daysBetween } from "#lib/dates.js"; import { daysBetween } from "#lib/dates.js";
import { formatDate, toNumber } from "#lib/format.js"; import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.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 { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -126,33 +123,15 @@
let armed = $state(false); let armed = $state(false);
let failure = $state(""); 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(() => ({ const accounts = createQuery(() => ({
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
queryFn: () => queryFn: () =>
all<Account>("/v1/accounts", { all<Account>("/v1/accounts", {
...balanceRange(period.start, period.end), ...balanceRange(period.start, period.end),
...(showInactive ? {} : { active: String(active) }), active: String(active),
type, type,
}), }),
queryKey: [ queryKey: ["accounts-index", type, active, period.start, period.end],
"accounts-index",
type,
showInactive ? "all" : active,
period.start,
period.end,
],
})); }));
const others = createQuery(() => ({ const others = createQuery(() => ({
queryFn: () => queryFn: () =>
@@ -203,8 +182,6 @@
function sortValue(item: Item<Account>, key: Key): string | number { function sortValue(item: Item<Account>, key: Key): string | number {
const a = item.attributes; const a = item.attributes;
const shown = (value: string | null | undefined) =>
display.convert(toNumber(value), a.currency_code) ?? toNumber(value);
switch (key) { switch (key) {
case "name": case "name":
return a.name.toLowerCase(); return a.name.toLowerCase();
@@ -219,15 +196,15 @@
case "number": case "number":
return formatIban(a); return formatIban(a);
case "balance": case "balance":
return shown(a.current_balance); return toNumber(a.current_balance);
case "due": case "due":
return shown(a.debt_amount); return toNumber(a.debt_amount);
case "active": case "active":
return a.active ? 1 : 0; return a.active ? 1 : 0;
case "activity": case "activity":
return a.last_activity ?? ""; return a.last_activity ?? "";
default: default:
return shown(a.balance_difference); return toNumber(a.balance_difference);
} }
} }
@@ -251,12 +228,8 @@
const sorted = $derived.by(() => { const sorted = $derived.by(() => {
const list = [...matching]; const list = [...matching];
if (!sortKey) { if (!sortKey) {
const rank = (item: Item<Account>) =>
perCurrency ? (ROLE_RANK[item.attributes.account_role ?? ""] ?? 5) : 0;
return list.sort( return list.sort(
(x, y) => (x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
rank(x) - rank(y) ||
(x.attributes.order ?? 0) - (y.attributes.order ?? 0)
); );
} }
const dir = descending ? -1 : 1; const dir = descending ? -1 : 1;
@@ -269,48 +242,10 @@
return (a > b ? 1 : -1) * dir; 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( const grouped = $derived(
sections || sorted.some((item) => item.attributes.object_group_title) sorted.some((item) => item.attributes.object_group_title)
); );
const groups = $derived.by(() => { const groups = $derived.by(() => {
if (sections) {
return byCurrency(
sorted,
(item) => item.attributes.currency_code ?? ""
).map(([code, items]) => ({ items, title: code }));
}
if (!grouped) { if (!grouped) {
return [{ items: sorted, title: "" }]; return [{ items: sorted, title: "" }];
} }
@@ -334,6 +269,34 @@
const rows = $derived(groups.flatMap((g) => g.items)); const rows = $derived(groups.flatMap((g) => g.items));
const index = $derived(new Map(rows.map((item, i) => [item.id, i]))); 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( const title = $derived(
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)` active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
); );
@@ -364,18 +327,6 @@
} }
}, },
}, },
...(perCurrency
? [
{
group: "Accounts",
keys: "i",
label: showInactive
? "Hide inactive accounts"
: "Show inactive accounts",
run: () => goto(inactiveHref(), { replace: true }),
},
]
: []),
], ],
id: "accounts", id: "accounts",
items: () => rows, items: () => rows,
@@ -384,18 +335,6 @@
}); });
nav.use(); 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) { function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) { if (event.key === "Escape" && filter) {
filter = ""; filter = "";
@@ -478,47 +417,6 @@
} }
</script> </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 <PageHeader
crumbs={[{ label: title }]} crumbs={[{ label: title }]}
subtitle={active ? undefined : "Archive"} subtitle={active ? undefined : "Archive"}
@@ -604,15 +502,7 @@
</thead> </thead>
<tbody> <tbody>
{#each groups as group (group.title)} {#each groups as group (group.title)}
{#if sections} {#if grouped}
<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"> <tr class="h-8 border-cladd-outline border-t">
<th <th
class="px-3 text-left font-semibold text-[12px] text-cladd-fg" class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
@@ -627,9 +517,7 @@
{@const a = item.attributes} {@const a = item.attributes}
{@const i = index.get(item.id) ?? -1} {@const i = index.get(item.id) ?? -1}
<tr <tr
class="h-7 border-cladd-outline/60 border-t {rowClass( class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
i
)} {a.active ? "" : "opacity-60"}"
data-id={item.id} data-id={item.id}
id={nav.rowId(i)} id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)} onclick={(event) => nav.click(i, event)}
@@ -715,19 +603,40 @@
</tr> </tr>
{/if} {/if}
</tbody> </tbody>
{#if totals.length > 0 && !sections} {#if totals.length > 0}
<tfoot class="border-cladd-outline border-t-2"> <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)} {#each totals as t (t.code)}
<tr class="h-7"> <tr class="h-7">
{@render sums([t], `Total in ${t.code}`)} {#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}
</tr> </tr>
{/each} {/each}
{/if}
</tfoot> </tfoot>
{/if} {/if}
</table> </table>
@@ -781,17 +690,7 @@
{#if failure} {#if failure}
<span class="text-expense">{failure}</span> <span class="text-expense">{failure}</span>
{/if} {/if}
{#if perCurrency && (showInactive || otherCount > 0)} {#if active && 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 <a
class="ml-auto text-cladd-fg-soft hover:underline" class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{type}" href="/inactive-accounts/{type}"
@@ -1,260 +0,0 @@
<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>
+3 -25
View File
@@ -8,7 +8,6 @@
currency, currency,
money, money,
} from "#lib/components/dashboard/data.js"; } from "#lib/components/dashboard/data.js";
import { display } from "#lib/money/display.svelte.js";
import type { Group } from "#lib/transactions/split.js"; import type { Group } from "#lib/transactions/split.js";
const TOP = 6; const TOP = 6;
@@ -42,29 +41,13 @@
.filter((s) => s.type === kind) .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( const sections = $derived(
buckets.map(([code, list]) => { byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
const sums = new Map<string, Bar>(); const sums = new Map<string, Bar>();
for (const split of list) { for (const split of list) {
const [label, href] = pick(split); const [label, href] = pick(split);
const bar = sums.get(label) ?? { href, key: label, label, value: 0 }; const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
const value = Number(split.amount); bar.value += Number(split.amount);
bar.value += merged
? (display.convert(value, split.currency_code) ?? 0)
: value;
sums.set(label, bar); sums.set(label, bar);
} }
const sorted = [...sums.values()].sort((a, b) => b.value - a.value); const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
@@ -80,12 +63,7 @@
}, },
] ]
: sorted; : sorted;
const format = money(currency(code)); return { bars, code, format: money(currency(code)) };
return {
bars,
code,
format: approx ? (value: number) => `≈${format(value)}` : format,
};
}) })
); );
</script> </script>
-214
View File
@@ -1,214 +0,0 @@
<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>
@@ -1,61 +0,0 @@
<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
@@ -1,355 +0,0 @@
<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}
@@ -1,148 +0,0 @@
<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}
@@ -1,707 +0,0 @@
<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>
@@ -1,72 +0,0 @@
<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>
@@ -1,58 +0,0 @@
<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>
@@ -1,46 +0,0 @@
<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>
+6 -33
View File
@@ -16,7 +16,6 @@
type ViewRange, type ViewRange,
} from "#lib/dates.js"; } from "#lib/dates.js";
import { toNumber } from "#lib/format.js"; import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period, periodLabel } from "#lib/period.svelte.js"; import { period, periodLabel } from "#lib/period.svelte.js";
const COUNT = 12; const COUNT = 12;
@@ -51,7 +50,6 @@
})); }));
interface Flow { interface Flow {
approx?: boolean;
code: string; code: string;
earned: number; earned: number;
spent: number; spent: number;
@@ -78,34 +76,12 @@
} }
return map; 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( const scale = $derived(
Math.max( Math.max(
1, 1,
...[...shown.values()].flatMap((list) => ...[...byStart.values()].flatMap((list) =>
list list
.filter((f) => f.code === main) .filter((f) => f.code === code)
.map((f) => Math.max(f.earned, f.spent)) .map((f) => Math.max(f.earned, f.spent))
) )
) )
@@ -119,7 +95,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" 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)} {#each blocks as block (block.start)}
{@const list = (shown.get(block.start) ?? []).filter( {@const list = (byStart.get(block.start) ?? []).filter(
(f) => f.earned || f.spent (f) => f.earned || f.spent
)} )}
<li class="border-cladd-outline/60 border-r border-b"> <li class="border-cladd-outline/60 border-r border-b">
@@ -141,20 +117,17 @@
{/if} {/if}
{#each list as flow (flow.code)} {#each list as flow (flow.code)}
{@const fmt = money(currency(flow.code))} {@const fmt = money(currency(flow.code))}
{@const mark = flow.approx ? "≈" : ""}
<span <span
class="num flex flex-wrap justify-between gap-x-2 text-[12px]" class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
> >
{#if flow.earned} {#if flow.earned}
<span class="text-income">{mark}+{fmt(flow.earned)}</span> <span class="text-income">+{fmt(flow.earned)}</span>
{/if} {/if}
{#if flow.spent} {#if flow.spent}
<span class="ml-auto text-expense" <span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
>{mark}−{fmt(flow.spent)}</span
>
{/if} {/if}
</span> </span>
{#if flow.code === main} {#if flow.code === code}
<span aria-hidden="true" class="flex h-1 gap-0.5"> <span aria-hidden="true" class="flex h-1 gap-0.5">
<span class="flex flex-1 justify-end"> <span class="flex flex-1 justify-end">
<span <span
+6 -19
View File
@@ -56,7 +56,6 @@
let busy = $state(false); let busy = $state(false);
let status = $state(""); let status = $state("");
let failure = $state(""); let failure = $state("");
let pressed = $state(false);
const account = createQuery(() => ({ const account = createQuery(() => ({
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`), queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
@@ -180,17 +179,6 @@
const canCorrect = $derived(Boolean(reconciliationId)); const canCorrect = $derived(Boolean(reconciliationId));
const correcting = $derived(correct && canCorrect && difference !== 0); const correcting = $derived(correct && canCorrect && difference !== 0);
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess); 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) { function setDates(nextStart: string, nextEnd: string) {
if (!(nextStart && nextEnd) || nextStart > nextEnd) { if (!(nextStart && nextEnd) || nextStart > nextEnd) {
@@ -253,8 +241,7 @@
} }
async function store() { async function store() {
pressed = true; if (busy || !ready || (selected.length === 0 && !correcting)) {
if (busy || hint) {
return; return;
} }
busy = true; busy = true;
@@ -351,7 +338,7 @@
> >
</td> </td>
<td class="whitespace-nowrap px-3 text-right"> <td class="whitespace-nowrap px-3 text-right">
<Amount amount={row.amount} exact {...cur} /> <Amount amount={row.amount} {...cur} />
</td> </td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell"> <td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.source_name} {row.split.source_name}
@@ -538,7 +525,10 @@
</fieldset> </fieldset>
{/if} {/if}
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Button disabled={busy} onclick={store} type="button" <Button
disabled={busy || !ready || (selected.length === 0 && !correcting)}
onclick={store}
type="button"
>Store reconciliation</Button >Store reconciliation</Button
> >
<span class="flex gap-1"> <span class="flex gap-1">
@@ -546,9 +536,6 @@
<span class="kbd">{key}</span> <span class="kbd">{key}</span>
{/each} {/each}
</span> </span>
{#if hint}
<span class="text-[12px] text-expense" role="alert">{hint}</span>
{/if}
</div> </div>
{#if status} {#if status}
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p> <p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
@@ -1,280 +0,0 @@
<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>
@@ -1,130 +0,0 @@
<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>
@@ -1,106 +0,0 @@
<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> = { export const DIRECTION_LABEL: Record<string, string> = {
credit: "Owed to you", credit: "Owed to me",
debit: "You owe", debit: "I owe",
}; };
export const PERIOD_LABEL: Record<string, string> = { export const PERIOD_LABEL: Record<string, string> = {
+7 -19
View File
@@ -4,7 +4,7 @@
import { invalidate } from "#lib/api/lists.js"; import { invalidate } from "#lib/api/lists.js";
import type { Range } from "#lib/dates.js"; import type { Range } from "#lib/dates.js";
import { formatMoney } from "#lib/format.js"; import { formatMoney } from "#lib/format.js";
import { type Line, lineMoney, parseAmount, saveLimit } from "./data.js"; import { type Line, parseAmount, saveLimit } from "./data.js";
export type Move = 0 | 1 | -1; export type Move = 0 | 1 | -1;
@@ -13,7 +13,6 @@
budgetId, budgetId,
range, range,
editing, editing,
suggested = null,
trap = true, trap = true,
onedit, onedit,
ondone, ondone,
@@ -22,7 +21,6 @@
budgetId: string; budgetId: string;
range: Range; range: Range;
editing: boolean; editing: boolean;
suggested?: number | null;
trap?: boolean; trap?: boolean;
onedit: () => void; onedit: () => void;
ondone: (move: Move) => void; ondone: (move: Move) => void;
@@ -35,7 +33,7 @@
$effect(() => { $effect(() => {
if (editing) { if (editing) {
untrack(() => { untrack(() => {
value = String(line.amount || suggested || ""); value = line.amount ? String(line.amount) : "";
problem = ""; problem = "";
}); });
} }
@@ -125,20 +123,6 @@
{@attach focus} {@attach focus}
> >
</span> </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} {:else}
<button <button
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
@@ -149,7 +133,11 @@
type="button" type="button"
> >
{line.amount {line.amount
? lineMoney(line, line.amount) ? formatMoney({
amount: line.amount,
decimals: line.cur.decimals,
symbol: line.cur.symbol,
})
: `${line.cur.symbol} set amount`} : `${line.cur.symbol} set amount`}
</button> </button>
{/if} {/if}
+2 -4
View File
@@ -4,7 +4,7 @@
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js"; import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
import { period, periodLabel } from "#lib/period.svelte.js"; import { period, periodLabel } from "#lib/period.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { keptParams, periodPath } from "./period-path.svelte.js"; import { periodPath } from "./period-path.svelte.js";
const COUNT = 12; const COUNT = 12;
@@ -24,9 +24,7 @@
const before = $derived(walk(-1)); const before = $derived(walk(-1));
const after = $derived(walk(1)); const after = $derived(walk(1));
const kept = $derived(keptParams()); const href = (range: Range) => periodPath(base, range.start, range.end);
const href = (range: Range) =>
`${periodPath(base, range.start, range.end)}${kept}`;
function pick(event: Event) { function pick(event: Event) {
const select = event.currentTarget as HTMLSelectElement; const select = event.currentTarget as HTMLSelectElement;
+146
View File
@@ -0,0 +1,146 @@
<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>
+283 -84
View File
@@ -1,28 +1,41 @@
<script lang="ts"> <script lang="ts">
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js"; import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js"; import { all, invalidate } from "#lib/api/lists.js";
import type { Budget, BudgetLimit, One } from "#lib/api/types.js"; import type {
Budget,
BudgetLimit,
One,
TransactionGroup,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte"; import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import type { Lane, StackRow } from "#lib/components/charts/chart.js"; import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
import Dimension from "#lib/components/dimension/Dimension.svelte"; import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
import LimitLine from "#lib/components/dimension/LimitLine.svelte"; import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
import LimitStrip from "#lib/components/dimension/LimitStrip.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 PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js"; import { Button } from "#lib/components/ui/button/index.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js"; import { addDays, contains, today } from "#lib/dates.js";
import { budgetSpec } from "#lib/dimension/spec.js"; import { formatMoney, formatRange, toNumber } from "#lib/format.js";
import {
limitFacts,
limitsIn,
stripsOf,
withLimits,
} from "#lib/dimension/stats.js";
import { useKeys } from "#lib/keys.svelte.js"; import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { longMonth } from "#lib/reports/board.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
import { limitRange } from "./data.js"; import { limitRange } from "./data.js";
let { id, limitId }: { id: string; limitId?: string } = $props(); let { id, limitId }: { id: string; limitId?: string } = $props();
@@ -35,43 +48,166 @@
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`), queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
queryKey: ["budget-limits", id], queryKey: ["budget-limits", id],
})); }));
const model = new DimensionModel(() => ({ spec: budgetSpec(id) })); const groups = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
queryKey: ["budget-transactions", id],
}));
const name = $derived(budget.data?.data.attributes.name ?? ""); const name = $derived(budget.data?.data.attributes.name ?? "");
const list = $derived(limits.data ?? []); const sortedLimits = $derived(
const limit = $derived(list.find((l) => l.id === limitId)); [...(limits.data ?? [])].sort((a, b) =>
const shown = $derived(limitsIn(list, model.into)); (b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
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 strips = $derived(stripsOf(shown, model.main, model.months)); const limit = $derived(sortedLimits.find((l) => l.id === limitId));
const range = $derived(limit ? limitRange(limit) : null);
$effect(() => { const allRows = $derived(
if (limit) { splitRows(groups.data ?? [], (s) => s.budget_id === id)
const range = limitRange(limit); );
untrack(() => period.set(range)); 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,
} }
);
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));
}); });
$effect(() => {
const { start, end } = period; const view = $derived(period.view);
untrack(() => { const history = $derived.by(() => {
const range = limit && limitRange(limit); const first = firstDay(inCur) ?? period.start;
if (range && (range.start !== start || range.end !== end)) { const end = period.end > first ? period.end : today();
setTimeout(() => const ranges = periodRanges(end, view, 400)
goto(`/budgets/show/${id}?start=${start}&end=${end}`, { .filter((r) => r.end >= first)
replace: true, .reverse();
reset: false, return ranges.map((r) => {
}) const spent = inRange(inCur, r).reduce(
(sum, row) => sum + toNumber(row.split.amount),
0
);
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() { async function remove() {
await api(`/v1/budgets/${id}`, { method: "DELETE" }); await api(`/v1/budgets/${id}`, { method: "DELETE" });
@@ -91,14 +227,16 @@
<PageHeader <PageHeader
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]} crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
subtitle={period.label} subtitle={limit && range
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
: undefined}
title={name || "Budget"} title={name || "Budget"}
> >
{#snippet actions()} {#snippet actions()}
<Button <Button
href="/budgets/edit/{id}" href="/budgets/edit/{id}"
size="sm" size="sm"
title="Shift+E" title="Edit budget (E)"
variant="outline" variant="outline"
>Edit</Button >Edit</Button
> >
@@ -106,50 +244,111 @@
{/snippet} {/snippet}
</PageHeader> </PageHeader>
{#snippet header( <div class="flex flex-col gap-4">
lane: Lane, {#if limit && range}
row: StackRow | undefined <Box
)} title="Chart for all transactions for budget &quot;{name}&quot; between {formatRange(
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span> range.start,
{#if row} range.end
<span class="text-cladd-fg-soft">{longMonth(row.key)}</span> )} in {cur.code}"
<span class="num font-semibold text-cladd-fg"
>{lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))}</span
>
{#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
>
limit</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} {/if}
{/if} </Box>
{#if lane.typical} {:else}
<span class="flex items-center gap-1.5 text-cladd-fg-soft"> <Box title="Chart of all transactions for budget &quot;{name}&quot;">
<span {#snippet tools()}
aria-hidden="true" <CurrencyTabs {codes} current={code} />
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}
{/snippet} {/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}
<Dimension <div class="grid gap-4 lg:grid-cols-3">
{header} <BreakdownBox items={byCategory} title="Expenses by category" />
{model} <BreakdownBox items={bySource} title="Expenses by asset account" />
noun="purchases" <BreakdownBox
shape={(lanes) => withLimits(lanes, shown)} 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"
>
{#snippet links()}
{#if limit}
<a class="text-brand hover:underline" href="/budgets/show/{id}"
>Show everything</a
> >
{#snippet extra()}
{#if !limits.isPending}
<LimitLine {facts} />
{/if} {/if}
{/snippet} {/snippet}
{#snippet below()} </GroupsBox>
<LimitStrip budgetId={id} cursor={model.focus} {strips} /> <nav
{/snippet} aria-label="Budgeted amounts"
</Dimension> 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>
</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>
-118
View File
@@ -1,118 +0,0 @@
<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
@@ -1,674 +0,0 @@
<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}
+21 -182
View File
@@ -7,33 +7,22 @@ import type {
Currency, Currency,
InsightTotalEntry, InsightTotalEntry,
Item, Item,
Split, Page,
TransactionGroup,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { placesOf, sumsOf, withoutBudget } from "#lib/budgets/triage.js";
import { import {
add, add,
amountIn,
type Cur, type Cur,
fromEntries, fromEntries,
list, list,
type Sum, type Sum,
type Sums, type Sums,
} from "#lib/components/categories/sums.js"; } from "#lib/components/categories/sums.js";
import { import { contains, daysBetween, type Range, today } from "#lib/dates.js";
contains, import { toNumber } from "#lib/format.js";
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 { export interface Line {
amount: number | null; amount: number | null;
approx?: boolean;
cur: Cur & { id: string }; cur: Cur & { id: string };
key: string; key: string;
limit?: Item<BudgetLimit>; limit?: Item<BudgetLimit>;
@@ -54,16 +43,6 @@ export interface Pool {
spent: number; spent: number;
} }
export interface Top {
approx?: boolean;
available: number | null;
budgeted: number;
cur: Cur;
inBudgets: number;
outside: number;
spent: number;
}
export interface Pace { export interface Pace {
expected: number; expected: number;
fraction: number; fraction: number;
@@ -79,9 +58,6 @@ export interface BudgetPage {
total: Sum[]; 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 day = (value: string | undefined) => (value ?? "").slice(0, 10);
export const limitRange = (limit: Item<BudgetLimit>): Range => ({ export const limitRange = (limit: Item<BudgetLimit>): Range => ({
@@ -122,20 +98,23 @@ export async function loadBudgetPage(
currencies: Item<Currency>[] currencies: Item<Currency>[]
): Promise<BudgetPage> { ): Promise<BudgetPage> {
const query = { end: range.end, start: range.start }; const query = { end: range.end, start: range.start };
const [budgets, limits, available, total, groups] = await Promise.all([ const [budgets, limits, available, total, noBudget, noBudgetPage] =
await Promise.all([
all<Budget>("/v1/budgets", query), all<Budget>("/v1/budgets", query),
all<BudgetLimit>("/v1/budget-limits", query), all<BudgetLimit>("/v1/budget-limits", query),
all<AvailableBudget>("/v1/available-budgets", query), all<AvailableBudget>("/v1/available-budgets", query),
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }), api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
withoutBudget(range), api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
query: { ...query, limit: 1, type: "withdrawal" },
}),
]); ]);
const entries = placesOf(groups, isOpen).flatMap((place) => place.entries);
return { return {
available, available,
budgets, budgets,
limits, limits,
noBudget: sumsOf(entries), noBudget: insightSums(noBudget, currencies),
noBudgetCount: entries.length, noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
total: insightSums(total, currencies), total: insightSums(total, currencies),
}; };
} }
@@ -236,102 +215,6 @@ export function buildPools(
return result.length > 0 ? result : [pool(primary)]; 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 { export function pace(line: Line, now = today()): Pace | null {
if (!(line.amount && contains(line.range, now))) { if (!(line.amount && contains(line.range, now))) {
return null; return null;
@@ -342,60 +225,16 @@ export function pace(line: Line, now = today()): Pace | null {
return { expected, fraction: elapsed / total, over: -line.spent - expected }; return { expected, fraction: elapsed / total, over: -line.spent - expected };
} }
export function projection(line: Line, now = today()): number | null { export function perDay(line: Line, now = today()) {
if (!contains(line.range, now)) {
return null;
}
const total = daysBetween(line.range.start, line.range.end) + 1; const total = daysBetween(line.range.start, line.range.end) + 1;
const elapsed = daysBetween(line.range.start, now) + 1; const current = contains(line.range, now);
return (-line.spent * total) / elapsed; const passed = current ? daysBetween(line.range.start, now) + 1 : total;
} const left = current ? daysBetween(now, line.range.end) + 1 : total;
return {
export function safePerDay(line: Line, now = today()): number | null { left: line.amount === null ? null : (line.amount + line.spent) / left,
if (line.amount === null || !contains(line.range, now)) { past: line.range.end < now,
return null; spent: line.spent / passed,
} };
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; const SPACES = /\s/g;
@@ -7,14 +7,6 @@ export function periodPath(base: string, start: string, end: string) {
return `${base}/${start}/${end}?start=${start}&end=${end}`; 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) { export function usePeriodPath(base: () => string) {
$effect(() => { $effect(() => {
const { start, end } = page.params; const { start, end } = page.params;
@@ -33,9 +25,7 @@ export function usePeriodPath(base: () => string) {
const target = base(); const target = base();
setTimeout(() => { setTimeout(() => {
if (page.url.pathname.startsWith(`${target}/${from}`)) { if (page.url.pathname.startsWith(`${target}/${from}`)) {
goto(`${periodPath(target, start, end)}${keptParams()}`, { goto(periodPath(target, start, end), { replace: true });
replace: true,
});
} }
}); });
} }
@@ -0,0 +1,64 @@
<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>
@@ -0,0 +1,28 @@
<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,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import Amount, { type Tone } from "#lib/components/Amount.svelte"; 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"; import type { Sum } from "./sums.js";
let { let {
@@ -10,8 +8,6 @@
empty = "", empty = "",
class: className = "", class: className = "",
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props(); }: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
const merged = $derived(display.total(sums) !== null);
</script> </script>
{#if sums.length === 0} {#if sums.length === 0}
@@ -20,9 +16,6 @@
{/if} {/if}
{:else} {:else}
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}"> <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)} {#each sums as sum (sum.code)}
<Amount <Amount
amount={sum.amount} amount={sum.amount}
@@ -32,6 +25,5 @@
{tone} {tone}
/> />
{/each} {/each}
{/if}
</span> </span>
{/if} {/if}
+124 -28
View File
@@ -1,21 +1,32 @@
<script lang="ts"> <script lang="ts">
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js"; import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js"; import { all, invalidate } from "#lib/api/lists.js";
import type { Category, One } from "#lib/api/types.js"; import type { Category, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { import {
periodPath, periodPath,
usePeriodPath, usePeriodPath,
} from "#lib/components/budgets/period-path.svelte.js"; } from "#lib/components/budgets/period-path.svelte.js";
import ConfirmButton from "#lib/components/ConfirmButton.svelte"; import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Dimension from "#lib/components/dimension/Dimension.svelte"; import StackedBars from "#lib/components/charts/StackedBars.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js"; import { Button } from "#lib/components/ui/button/index.js";
import { DimensionModel } from "#lib/dimension/model.svelte.js"; import { formatDate, formatMoney } from "#lib/format.js";
import { categorySpec } from "#lib/dimension/spec.js";
import { useKeys } from "#lib/keys.svelte.js"; import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation"; 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 } = let { id, everything = false }: { id: string; everything?: boolean } =
$props(); $props();
@@ -27,12 +38,49 @@
queryFn: () => api<One<Category>>(`/v1/categories/${id}`), queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
queryKey: ["category", id], queryKey: ["category", id],
})); }));
const name = $derived(category.data?.data.attributes.name ?? ""); const groups = createQuery(() => ({
const model = new DimensionModel(() => ({ queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
everything, queryKey: ["category-transactions", id],
spec: categorySpec(id),
})); }));
const other = $derived(everything ? base : `${base}/all`);
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)
);
});
async function remove() { async function remove() {
await api(`/v1/categories/${id}`, { method: "DELETE" }); await api(`/v1/categories/${id}`, { method: "DELETE" });
@@ -47,12 +95,6 @@
label: "Edit category", label: "Edit category",
run: () => goto(`/categories/edit/${id}`), run: () => goto(`/categories/edit/${id}`),
}, },
{
group: "Category",
keys: "A",
label: everything ? "Show the current period" : "Show all time",
run: () => goto(other),
},
]); ]);
</script> </script>
@@ -61,17 +103,16 @@
{ href: "/categories", label: "Categories" }, { href: "/categories", label: "Categories" },
{ label: name || "Category" }, { label: name || "Category" },
]} ]}
subtitle={everything ? "All time" : period.label} subtitle={everything
? "All transactions"
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
title={name || "Category"} title={name || "Category"}
> >
{#snippet actions()} {#snippet actions()}
<Button href={other} size="sm" title="Shift+A" variant="outline"
>{everything ? "This period" : "All time"}</Button
>
<Button <Button
href="/categories/edit/{id}" href="/categories/edit/{id}"
size="sm" size="sm"
title="Shift+E" title="Edit category (E)"
variant="outline" variant="outline"
>Edit</Button >Edit</Button
> >
@@ -79,11 +120,66 @@
{/snippet} {/snippet}
</PageHeader> </PageHeader>
<Dimension <div class="flex flex-col gap-4">
{model} <div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
noun={model.dir === -1 ? "purchases" : "payments"} {#if !everything}
onmonth={(range) => <Box
everything title="Chart for all transactions for category &quot;{name}&quot; between {formatDate(
? goto(periodPath(base, range.start, range.end)) period.start
: period.set(range)} )} 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>
+56
View File
@@ -0,0 +1,56 @@
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 },
};
});
}
+20 -151
View File
@@ -11,7 +11,6 @@
type StackRow, type StackRow,
stack, stack,
textWidth, textWidth,
tones,
} from "./chart.js"; } from "./chart.js";
import Legend from "./Legend.svelte"; import Legend from "./Legend.svelte";
@@ -25,9 +24,6 @@
const TILTED_CHARS = 16; const TILTED_CHARS = 16;
const TILTED_CHAR_WIDTH = 4.9; const TILTED_CHAR_WIDTH = 4.9;
const LABEL_GAP = 13; const LABEL_GAP = 13;
const BARE_BOTTOM = 4;
const DOT_ROOM = 1.5;
const DOT_R = 3.5;
let { let {
keys, keys,
@@ -37,16 +33,6 @@
axisFormat = (value: number) => compactMoney(value), axisFormat = (value: number) => compactMoney(value),
label = "Bar chart", label = "Bar chart",
active = $bindable(null), active = $bindable(null),
selected = null,
reference = null,
partial = null,
dots,
dotColor = tones.income,
gutter,
legend: showLegend = true,
axis = true,
tilt = true,
keyboard = true,
onselect, onselect,
}: { }: {
keys: StackKey[]; keys: StackKey[];
@@ -56,63 +42,30 @@
axisFormat?: (value: number) => string; axisFormat?: (value: number) => string;
label?: string; label?: string;
active?: string | null; active?: string | null;
selected?: string | null; onselect?: (row: StackRow) => void;
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(); } = $props();
const uid = $props.id();
let width = $state(0); let width = $state(0);
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) }))); const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y))); 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( const yBase = $derived(
scaleLinear() scaleLinear()
.domain([ .domain([
Math.min(0, ...stacks.map((s) => s.negative)), Math.min(0, ...stacks.map((s) => s.negative)),
Math.max(stackTop, reference ?? 0, ...dotValues) || 1, Math.max(0, ...stacks.map((s) => s.positive)) || 1,
]) ])
.nice(yCount) .nice(yCount)
); );
const yTicks = $derived(yBase.ticks(yCount)); const yTicks = $derived(yBase.ticks(yCount));
const left = $derived( const left = $derived(
gutter ?? Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
Math.ceil(
Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10
)
); );
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length)); const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
const crowded = $derived( const tilted = $derived(
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70 rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
); );
const tilted = $derived(tilt && crowded); const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
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 labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
const y = $derived(yBase.copy().range([height - bottom, TOP])); const y = $derived(yBase.copy().range([height - bottom, TOP]));
const x = $derived( const x = $derived(
@@ -135,13 +88,7 @@
const activeIndex = $derived( const activeIndex = $derived(
active ? rows.findIndex((r) => r.key === active) : -1 active ? rows.findIndex((r) => r.key === active) : -1
); );
const selectedIndex = $derived( const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
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 fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
const legend = $derived<LegendItem[]>( const legend = $derived<LegendItem[]>(
readRow readRow
@@ -166,31 +113,17 @@
: text; : text;
} }
function select(row: StackRow | undefined, segment?: string) { function select(row: StackRow | undefined) {
if (!row) { if (!row) {
return; return;
} }
if (onselect) { if (onselect) {
onselect(row, segment); onselect(row);
} else if (row.href) { } else if (row.href) {
goto(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)); const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
function pick(event: PointerEvent) { function pick(event: PointerEvent) {
@@ -203,9 +136,6 @@
} }
function onkeydown(event: KeyboardEvent) { function onkeydown(event: KeyboardEvent) {
if (!keyboard) {
return;
}
if (event.key === "Enter") { if (event.key === "Enter") {
select(rows[activeIndex]); select(rows[activeIndex]);
return; return;
@@ -249,7 +179,6 @@
</script> </script>
<div class="flex min-w-0 flex-col gap-2"> <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"> <div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
{#if keys.length > 1 && readRow} {#if keys.length > 1 && readRow}
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg" <span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
@@ -258,7 +187,6 @@
{/if} {/if}
<Legend class="min-w-0 flex-1" items={legend} /> <Legend class="min-w-0 flex-1" items={legend} />
</div> </div>
{/if}
<div <div
aria-label={label} aria-label={label}
aria-valuemax={Math.max(0, rows.length - 1)} aria-valuemax={Math.max(0, rows.length - 1)}
@@ -266,45 +194,19 @@
aria-valuenow={Math.max(0, activeIndex)} aria-valuenow={Math.max(0, activeIndex)}
aria-valuetext={valueText} aria-valuetext={valueText}
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}" class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
onblur={() => keyboard && (active = null)} onblur={() => (active = null)}
onclick={(event) => onclick={() => select(rows[activeIndex])}
select(rows[activeIndex], segmentAt(event, activeIndex))} onfocus={() => (active ??= rows[0]?.key ?? null)}
onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)}
{onkeydown} {onkeydown}
onpointerleave={() => (active = null)} onpointerleave={() => (active = null)}
onpointermove={pick} onpointermove={pick}
role="slider" role="slider"
tabindex={keyboard ? 0 : -1} tabindex="0"
style:height="{height}px" style:height="{height}px"
bind:clientWidth={width} bind:clientWidth={width}
> >
{#if width > 0 && rows.length > 0} {#if width > 0 && rows.length > 0}
<svg aria-hidden="true" class="block" {height} {width}> <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)} {#each yTicks as tick (tick)}
<line <line
stroke="var(--cladd-outline)" stroke="var(--cladd-outline)"
@@ -337,17 +239,16 @@
{@const dim = activeIndex >= 0 && activeIndex !== i} {@const dim = activeIndex >= 0 && activeIndex !== i}
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}> <g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
{#each s.segments as seg, j (seg.key)} {#each s.segments as seg, j (seg.key)}
{@const d = segmentPath( <path
d={segmentPath(
cx, cx,
seg.from, seg.from,
seg.to, seg.to,
seg.outer, seg.outer,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0 j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)} )}
<path {d} style:fill={seg.color} /> style:fill={seg.color}
{#if partial === s.row.key} />
<path {d} fill="url(#{uid}-hatch)" />
{/if}
{/each} {/each}
</g> </g>
{#if capLabels && s.segments[0]} {#if capLabels && s.segments[0]}
@@ -367,7 +268,7 @@
</text> </text>
{/if} {/if}
{/if} {/if}
{#if axis && tilted && i % labelEvery === 0} {#if tilted && i % labelEvery === 0}
<text <text
fill={activeIndex === i fill={activeIndex === i
? "var(--cladd-fg)" ? "var(--cladd-fg)"
@@ -381,52 +282,20 @@
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH)) Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
)} )}
</text> </text>
{:else if axis && !tilted && i % flatEvery === 0} {:else if !tilted}
<text <text
fill={activeIndex === i || selectedIndex === i fill={activeIndex === i
? "var(--cladd-fg)" ? "var(--cladd-fg)"
: "var(--cladd-fg-soft)"} : "var(--cladd-fg-soft)"}
font-size="11" font-size="11"
font-weight={selectedIndex === i ? 600 : 400}
text-anchor="middle" text-anchor="middle"
x={cx} x={cx}
y={height - 6} y={height - 6}
> >
{flatEvery > 1 {short(s.row.label, Math.floor(x.step() / 6.4))}
? s.row.label
: short(s.row.label, Math.floor(x.step() / 6.4))}
</text> </text>
{/if} {/if}
{/each} {/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> </svg>
{/if} {/if}
</div> </div>
-184
View File
@@ -1,184 +0,0 @@
<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,18 +39,6 @@ export interface StackRow {
values: Record<string, number>; 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 { export interface MeterSegment {
color?: string; color?: string;
label?: string; label?: string;
@@ -0,0 +1,188 @@
<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>
@@ -1,407 +0,0 @@
<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,7 +4,6 @@
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js"; import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte"; import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js"; import { contains, today } from "#lib/dates.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -17,7 +16,6 @@
type Cur, type Cur,
hasPrimary, hasPrimary,
hrefWith, hrefWith,
mergeable,
money, money,
seriesPoints, seriesPoints,
setCurrency, setCurrency,
@@ -59,41 +57,34 @@
interface Panel { interface Panel {
cur: Cur; cur: Cur;
label: string;
series: LineSeries[]; 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[] { function sum(group: ChartSet[]): LinePoint[] {
const totals = new Map<string, number>(); const totals = new Map<string, number>();
for (const set of group) { for (const set of group) {
for (const p of points(set)) { for (const p of seriesPoints(set, converted)) {
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y); totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
} }
} }
return [...totals.entries()].map(([x, y]) => ({ x, y })); return [...totals.entries()].map(([x, y]) => ({ x, y }));
} }
function panel(cur: Cur, label: string, group: ChartSet[]): Panel { 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]);
const format = money(cur); const format = money(cur);
if (mode === "net-worth") { if (mode === "net-worth") {
const included = group.filter((s) => !excluded.has(s.label)); const included = group.filter((s) => !excluded.has(s.label));
return { return {
cur, cur,
label,
series: [ series: [
{ {
area: true, area: true,
format, format,
key: "net-worth", key: "net-worth",
label: `Net worth (${label})`, label: `Net worth (${cur.code})`,
points: sum(included), points: sum(included),
strong: true, strong: true,
}, },
@@ -102,24 +93,16 @@
} }
return { return {
cur, cur,
label,
series: group.map((s) => ({ series: group.map((s) => ({
area: group.length === 1, area: group.length === 1,
format, format,
key: s.label, key: s.label,
label: s.label, label: s.label,
points: points(s), points: seriesPoints(s, converted),
})), })),
}; };
} });
});
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( const panelHeight = $derived(
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length))) Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
); );
@@ -178,10 +161,10 @@
{:else} {:else}
{#each panels as panel (panel.cur.code)} {#each panels as panel (panel.cur.code)}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#if panels.length > 1 || merged} {#if panels.length > 1}
<span <span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide" class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{panel.label}</span >{panel.cur.code}</span
> >
{/if} {/if}
<LineChart <LineChart
@@ -204,7 +187,7 @@
href="/accounts/asset" href="/accounts/asset"
>View your asset accounts</a >View your asset accounts</a
> >
{#if converted && !merged} {#if converted}
<span class="ml-auto text-cladd-fg-soft" <span class="ml-auto text-cladd-fg-soft"
>Converted to your primary currency</span >Converted to your primary currency</span
> >
@@ -11,13 +11,10 @@
} from "#lib/components/charts/chart.js"; } from "#lib/components/charts/chart.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte"; import StackedBars from "#lib/components/charts/StackedBars.svelte";
import { toNumber } from "#lib/format.js"; import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { import {
byCurrency, byCurrency,
type ChartSet, type ChartSet,
type Cur,
mergeable,
money, money,
setCurrency, setCurrency,
span, span,
@@ -47,50 +44,36 @@
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id])) new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
); );
const sets = $derived(chart.data ?? []); const groups = $derived(
const merged = $derived(mergeable(sets.map((s) => s.currency_code))); byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
const format = money(setCurrency(sets[0]));
function group(code: string, label: string, cur: Cur, list: ChartSet[]) { const rows: StackRow[] = sets
const format = money(cur); .map((s) => {
const totals = new Map<string, { budgeted: number; spent: number }>(); const budgeted = toNumber(s.entries.budgeted);
for (const s of list) { const spent = Math.abs(toNumber(s.entries.spent));
const rate = merged ? (display.rate(s.currency_code) ?? 1) : 1; const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
const total = totals.get(s.label) ?? { budgeted: 0, spent: 0 }; const id = ids.get(s.label);
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 { return {
format, format,
href: id ? `/budgets/show/${id}` : undefined, href: id ? `/budgets/show/${id}` : undefined,
key: name, key: s.label,
label: name, label: s.label,
note: note:
budgeted > 0 budgeted > 0
? `${format(spent)} of ${format(budgeted)}` ? `${format(spent)} of ${format(budgeted)}`
: format(spent), : format(spent),
values: { values: {
left: budgeted > 0 ? Math.max(0, budgeted - spent) : 0, left: Math.max(0, toNumber(s.entries.left)),
over: budgeted > 0 ? Math.max(0, spent - budgeted) : 0, over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
spent: budgeted > 0 ? Math.min(spent, budgeted) : spent, spent: inside,
}, },
}; };
}) })
.filter((r) => r.values.spent + r.values.left + r.values.over > 0); .filter((r) => r.values.spent + r.values.left + r.values.over > 0);
const limited = [...totals.values()].some((t) => t.budgeted > 0); const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }]; const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
return { code, keys, label, limited, rows }; return { code, keys, 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)); const visible = $derived(groups.filter((g) => g.rows.length > 0));
</script> </script>
@@ -118,9 +101,9 @@
{:else} {:else}
{#each visible as group (group.code)} {#each visible as group (group.code)}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#if visible.length > 1 || !group.limited || merged} {#if visible.length > 1 || !group.limited}
<span class="text-[11px] text-cladd-fg-soft"> <span class="text-[11px] text-cladd-fg-soft">
<span class="font-semibold tracking-wide">{group.label}</span> <span class="font-semibold tracking-wide">{group.code}</span>
{#if !group.limited} {#if !group.limited}
· spent, no budget amount set in {group.code} · spent, no budget amount set in {group.code}
{/if} {/if}
@@ -6,15 +6,12 @@
import BarChart from "#lib/components/charts/BarChart.svelte"; import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js"; import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js"; import { toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { import {
axis, axis,
byCurrency, byCurrency,
type ChartSet, type ChartSet,
type Cur,
hasPrimary, hasPrimary,
mergeable,
money, money,
proportional, proportional,
setCurrency, setCurrency,
@@ -37,43 +34,31 @@
const ids = $derived( const ids = $derived(
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id])) new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
); );
const sets = $derived(chart.data ?? []); const converted = $derived(hasPrimary(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)));
function group(code: string, label: string, cur: Cur, list: ChartSet[]) { const groups = $derived(
const totals = new Map<string, number>(); byCurrency(chart.data ?? [], (s) =>
for (const s of list) { 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) => {
const entries = (converted ? s.pc_entries : s.entries) as Record< const entries = (converted ? s.pc_entries : s.entries) as Record<
string, string,
string string
>; >;
const rate = merged ? (display.rate(curOf(s).code) ?? 1) : 1; const id = ids.get(s.label);
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 { return {
href: id ? `/categories/show/${id}` : NO_CATEGORY, href: id ? `/categories/show/${id}` : NO_CATEGORY,
key: name, key: s.label,
label: name, label: s.label,
value, value: Math.abs(toNumber(entries?.spent)),
}; };
}) })
.filter((b) => b.value > 0) .filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value); .sort((a, b) => b.value - a.value);
return { bars, code, cur, label }; return { bars, code, cur };
} })
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)); const visible = $derived(groups.filter((g) => g.bars.length > 0));
</script> </script>
@@ -96,10 +81,10 @@
{:else} {:else}
{#each visible as group (group.code)} {#each visible as group (group.code)}
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1 || merged} {#if visible.length > 1}
<span <span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide" class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>Spent in {group.label}</span >Spent in {group.code}</span
> >
{/if} {/if}
<BarChart <BarChart
+72 -122
View File
@@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { isDebt, owed, stance } from "#lib/accounts/ledger.js"; import { showUrl } from "#lib/components/accounts/links.js";
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import { useColumn } from "#lib/dashboard/column.svelte.js"; import BarChart from "#lib/components/charts/BarChart.svelte";
import { formatMoney } from "#lib/format.js"; import { tones } from "#lib/components/charts/chart.js";
import { display } from "#lib/money/display.svelte.js"; import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation"; import { accountsQuery, byCurrency, money } from "./data.js";
import { accountsQuery, byCurrency } from "./data.js";
const liabilities = createQuery(() => ({ const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end), ...accountsQuery("liabilities", period.start, period.end),
@@ -18,138 +16,90 @@
const groups = $derived( const groups = $derived(
byCurrency( byCurrency(
(liabilities.data ?? []).filter( (liabilities.data ?? []).filter(
(a) => isDebt(a) && stance(a.attributes) !== "settled" (a) =>
a.attributes.liability_type === "debt" &&
toNumber(a.attributes.debt_amount) !== 0
), ),
(a) => a.attributes.currency_code ?? "" (a) => a.attributes.currency_code ?? ""
).map(([code, list]) => { ).map(([code, list]) => {
const first = list[0].attributes; const first = list[0].attributes;
const cur = { const format = money({
code, code,
decimals: first.currency_decimal_places ?? 2, decimals: first.currency_decimal_places ?? 2,
symbol: first.currency_symbol ?? code, symbol: first.currency_symbol ?? code,
}; });
const rows = list const bars = list
.map((a) => ({ .map((a) => {
id: a.id, const due = Math.abs(toNumber(a.attributes.debt_amount));
key: `debt-${a.id}`, const value =
name: a.attributes.name, a.attributes.liability_direction === "credit" ? due : -due;
value: owed(a.attributes),
}))
.sort((x, y) => y.value - x.value);
return { return {
code, color: value > 0 ? tones.income : tones.expense,
cur, href: showUrl(a.id),
largest: Math.max(...rows.map((r) => Math.abs(r.value))), key: a.id,
net: rows.reduce((s, r) => s + r.value, 0), label: a.attributes.name,
rows, value,
}; };
}) })
); .sort((x, y) => y.value - x.value);
const owed = bars
const nets = $derived.by(() => { .filter((b) => b.value > 0)
const total = display.total( .reduce((s, b) => s + b.value, 0);
groups.map((g) => ({ ...g.cur, amount: g.net })) const owe = bars
); .filter((b) => b.value < 0)
if (total === null) { .reduce((s, b) => s - b.value, 0);
return groups.map((g) => ({ return { bars, code, format, owe, owed };
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> </script>
{#snippet more()} {#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()}
<a <a
class="text-brand hover:underline dark:text-(--ff-series-1)" class="text-brand dark:text-(--ff-series-1) hover:underline"
href="/accounts/liabilities" href="/accounts/liabilities"
>See your liabilities</a >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> </Box>
{/if} {/if}
-181
View File
@@ -1,181 +0,0 @@
<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>
@@ -1,183 +0,0 @@
<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>
+33 -76
View File
@@ -8,15 +8,8 @@
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import type { Component, Snippet } from "svelte"; import type { Component, Snippet } from "svelte";
import Meter from "#lib/components/charts/Meter.svelte"; import Meter from "#lib/components/charts/Meter.svelte";
import { import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
daysBetween,
parse,
periodOf,
shiftPeriod,
today,
} from "#lib/dates.js";
import { currentLocale, formatMoney } from "#lib/format.js"; import { currentLocale, formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js"; import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js"; import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
@@ -50,49 +43,35 @@
}); });
const year = $derived(today().slice(0, 4)); const year = $derived(today().slice(0, 4));
const absolute = (list: SummaryEntry[]) => const fmt = (e: SummaryEntry, abs = false) =>
list.map((e) => ({ ...e, amount: Math.abs(e.amount) })); formatMoney({
amount: abs ? Math.abs(e.amount) : e.amount,
function lines(list: SummaryEntry[], abs = false): string[] { code: e.code,
const parts = abs ? absolute(list) : list; decimals: e.decimals,
return display.total(parts) symbol: e.symbol,
? [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( function compare(
kind: string, kind: string,
words: [string, string], words: [string, string],
upIsGood = true upIsGood = true
): Delta | null { ): Delta | null {
const sum = display.total(now[kind] ?? []); const a = now[kind]?.[0];
const a = sum ?? now[kind]?.[0]; const b = a && before[kind]?.find((e) => e.code === a.code);
const b = sum
? display.total(before[kind] ?? [])
: a && before[kind]?.find((e) => e.code === a.code);
if (!(a && b) || a.amount === b.amount) { if (!(a && b) || a.amount === b.amount) {
return null; return null;
} }
const diff = a.amount - b.amount; const diff = a.amount - b.amount;
const text = formatMoney({ ...a, amount: Math.abs(diff) });
return { return {
good: diff > 0 === upIsGood, good: diff > 0 === upIsGood,
text: `${sum?.converted ? "≈" : ""}${text} ${diff > 0 ? words[0] : words[1]}`, text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
up: diff > 0, up: diff > 0,
}; };
} }
const balance = $derived(now.balance ?? []); const balance = $derived(now.balance ?? []);
const earned = $derived(now.earned?.length ? flow(now.earned, 1) : ""); const earned = $derived(now.earned?.[0]);
const spent = $derived(now.spent?.length ? flow(now.spent, -1) : ""); const spent = $derived(now.spent?.[0]);
const unpaid = $derived(now["bills-unpaid"] ?? []); const unpaid = $derived(now["bills-unpaid"] ?? []);
const paid = $derived(now["bills-paid"] ?? []); const paid = $derived(now["bills-paid"] ?? []);
const left = $derived(now["left-to-spend"] ?? []); const left = $derived(now["left-to-spend"] ?? []);
@@ -102,23 +81,7 @@
if (left.some((e) => e.noBudgets)) { if (left.some((e) => e.noBudgets)) {
return "none"; return "none";
} }
const sum = display.total(left); return left.some((e) => e.amount > 0) ? "ok" : "over";
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( const leftTitle = $derived(
leftState === "none" leftState === "none"
@@ -134,11 +97,6 @@
}[leftState] }[leftState]
); );
const paidShare = $derived.by(() => { 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 code = unpaid[0]?.code ?? paid[0]?.code;
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0); const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0); const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
@@ -150,21 +108,20 @@
list: SummaryEntry[], list: SummaryEntry[],
abs = false abs = false
)} )}
{@const [main, ...rest] = lines(list, abs)} {#if list.length === 0}
{#if main === undefined}
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]"> <div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
0 0
</div> </div>
{:else} {:else}
<div <div
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]" class="truncate font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
> >
{main} {fmt(list[0], abs)}
</div> </div>
{#if rest.length > 0} {#if list.length > 1}
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft"> <div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
{#each rest as text (text)} {#each list.slice(1) as e (e.code)}
<span>{text}</span> <span>{fmt(e, abs)}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -185,7 +142,7 @@
{:else} {:else}
<ArrowDown class="size-3.5 shrink-0" /> <ArrowDown class="size-3.5 shrink-0" />
{/if} {/if}
<span <span class="truncate"
><span class="num">{delta.text}</span> ><span class="num">{delta.text}</span>
<span class="text-cladd-fg-soft">than {prevName}</span></span <span class="text-cladd-fg-soft">than {prevName}</span></span
> >
@@ -228,10 +185,10 @@
{#if earned || spent} {#if earned || spent}
<div class="num flex flex-wrap gap-x-2 text-[12px]"> <div class="num flex flex-wrap gap-x-2 text-[12px]">
{#if earned} {#if earned}
<span class="text-income">{earned}</span> <span class="text-income">+{fmt(earned, true)}</span>
{/if} {/if}
{#if spent} {#if spent}
<span class="text-expense">{spent}</span> <span class="text-expense">−{fmt(spent, true)}</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -246,21 +203,21 @@
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]} segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
/> />
{/if} {/if}
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft"> <div class="truncate text-[12px] text-cladd-fg-soft">
<span>Paid:</span> Paid:
{#each lines(paid, true) as text (text)} <span class="num"
<span class="num">{text}</span> >{paid.length ? paid.map((e) => fmt(e, true)).join(", ") : "0"}</span
{:else} >
<span class="num">0</span>
{/each}
</div> </div>
{/snippet} {/snippet}
{#snippet leftToSpend()} {#snippet leftToSpend()}
{@render values(left)} {@render values(left)}
{#if perDay} {#if left[0]?.sub}
<div class="text-[12px] text-cladd-fg-soft"> <div class="truncate text-[12px] text-cladd-fg-soft">
{leftState === "none" ? "" : "Per day: "}<span class="num">{perDay}</span> {leftState === "none" ? "" : "Per day: "}<span class="num"
>{left[0].sub}</span
>
</div> </div>
{/if} {/if}
{@render change(compare("left-to-spend", ["more", "less"]))} {@render change(compare("left-to-spend", ["more", "less"]))}
@@ -0,0 +1,146 @@
<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
@@ -1,201 +0,0 @@
<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,8 +4,7 @@
import type { PiggyBank } from "#lib/api/types.js"; import type { PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte"; import Meter from "#lib/components/charts/Meter.svelte";
import { percent, toNumber } from "#lib/format.js"; import { formatMoney, percent, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
const piggies = createQuery(() => lists.piggyBanks()); const piggies = createQuery(() => lists.piggyBanks());
@@ -19,14 +18,12 @@
); );
const fmt = (p: PiggyBank, amount: string | null | undefined) => const fmt = (p: PiggyBank, amount: string | null | undefined) =>
display.text([ formatMoney({
{
amount, amount,
code: p.currency_code, code: p.currency_code,
decimals: p.currency_decimal_places, decimals: p.currency_decimal_places,
symbol: p.currency_symbol, symbol: p.currency_symbol,
}, });
]);
</script> </script>
{#if saving.length > 0} {#if saving.length > 0}
@@ -4,11 +4,9 @@
import type { Bill } from "#lib/api/types.js"; import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte"; import Meter from "#lib/components/charts/Meter.svelte";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.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 { period } from "#lib/period.svelte.js";
import { parseSummary, rough, span, summaryQuery } from "./data.js"; import { parseSummary, span, summaryQuery } from "./data.js";
const DUE_LIMIT = 6; const DUE_LIMIT = 6;
@@ -38,30 +36,6 @@
) )
.filter((m) => m.total > 0) .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)[]) => const inPeriod = (dates: (string | null | undefined)[]) =>
dates dates
.map((d) => (d ?? "").slice(0, 10)) .map((d) => (d ?? "").slice(0, 10))
@@ -76,7 +50,7 @@
(b.attributes.paid_dates ?? []).map((p) => p.date) (b.attributes.paid_dates ?? []).map((p) => p.date)
).length; ).length;
const next = expected[paid]; const next = expected[paid];
if (!next || next >= today()) { if (!next) {
return []; return [];
} }
const amount = const amount =
@@ -87,6 +61,16 @@
}) })
.sort((x, y) => x.date.localeCompare(y.date)) .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> </script>
{#if (bills.data?.length ?? 0) > 0} {#if (bills.data?.length ?? 0) > 0}
@@ -100,23 +84,25 @@
<p class="text-cladd-fg-soft">Nothing expected in this period.</p> <p class="text-cladd-fg-soft">Nothing expected in this period.</p>
{:else} {:else}
<ul class="flex flex-col gap-2"> <ul class="flex flex-col gap-2">
{#each lines as m (m.key)} {#each meters as m (m.cur.code)}
<li class="flex flex-col gap-1"> <li class="flex flex-col gap-1">
<div <div
class="flex items-baseline justify-between gap-2 text-[12px]" class="flex items-baseline justify-between gap-2 text-[12px]"
> >
<span class="text-cladd-fg-soft" <span class="text-cladd-fg-soft"
><span class="num text-cladd-fg">{m.paid}</span> ><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
paid</span paid</span
> >
<span class="text-cladd-fg-soft" <span class="text-cladd-fg-soft"
><span class="num text-cladd-fg">{m.left}</span> ><span class="num text-cladd-fg"
>{fmt(m.total - m.paid, m.cur)}</span
>
to pay</span to pay</span
> >
</div> </div>
<Meter <Meter
max={m.max} max={m.total}
segments={[{ color: "var(--ff-series-3)", value: m.value }]} segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
/> />
</li> </li>
{/each} {/each}
@@ -125,7 +111,7 @@
{#if due.length > 0} {#if due.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft" <span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
>Not paid yet</span >Still to pay this period</span
> >
<ul class="flex flex-col"> <ul class="flex flex-col">
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)} {#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
@@ -141,14 +127,12 @@
>{d.bill.attributes.name}</span >{d.bill.attributes.name}</span
> >
<span class="num text-cladd-fg-soft" <span class="num text-cladd-fg-soft"
>{rough([ >~{formatMoney({
{
amount: d.amount, amount: d.amount,
code: d.bill.attributes.currency_code, code: d.bill.attributes.currency_code,
decimals: d.bill.attributes.currency_decimal_places, decimals: d.bill.attributes.currency_decimal_places,
symbol: d.bill.attributes.currency_symbol, symbol: d.bill.attributes.currency_symbol,
}, })}</span
])}</span
> >
</a> </a>
</li> </li>
+1 -20
View File
@@ -2,13 +2,7 @@ import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js"; import { all } from "#lib/api/lists.js";
import type { Account, BasicSummaryEntry } from "#lib/api/types.js"; import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
import { addDays, today } from "#lib/dates.js"; import { addDays, today } from "#lib/dates.js";
import { import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
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 { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
@@ -95,19 +89,6 @@ 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( export function parseSummary(
raw: Record<string, BasicSummaryEntry> | undefined raw: Record<string, BasicSummaryEntry> | undefined
): Record<string, SummaryEntry[]> { ): Record<string, SummaryEntry[]> {
@@ -1,161 +0,0 @@
<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>
@@ -1,144 +0,0 @@
<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}
@@ -1,148 +0,0 @@
<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
@@ -1,104 +0,0 @@
<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>
@@ -1,70 +0,0 @@
<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>
@@ -1,56 +0,0 @@
<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>
@@ -1,76 +0,0 @@
<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}
@@ -1,90 +0,0 @@
<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} />
+35 -46
View File
@@ -10,7 +10,6 @@
import Field from "#lib/components/inputs/Field.svelte"; import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js"; import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { formatMoney } from "#lib/format.js"; import { formatMoney } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js"; import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js"; import { ui } from "#lib/ui.svelte.js";
@@ -52,16 +51,15 @@
withdrawal: "withdrawal", withdrawal: "withdrawal",
}; };
const FIELDS: [string, string, string][] = [ const FIELDS: [string, string, string][] = [
["d", "description", "description"], ["alt+d", "description", "description"],
["f", "source", "source account"], ["alt+f", "source", "source account"],
["t", "destination", "destination account"], ["alt+t", "destination", "destination account"],
["w", "date", "date"], ["alt+w", "date", "date"],
["m", "amount", "amount"], ["alt+m", "amount", "amount"],
["c", "category", "category"], ["alt+k", "category", "category"],
["b", "budget", "budget"], ["alt+b", "budget", "budget"],
["s", "bill", "subscription"], ["alt+g", "tags", "tags"],
["#", "tags", "tags"], ["alt+r", "notes", "notes"],
["r", "notes", "notes"],
]; ];
const INLINE = new Set([ const INLINE = new Set([
"description", "description",
@@ -105,7 +103,6 @@
const originalType = start.type; const originalType = start.type;
let form = $state<Form>(start); let form = $state<Form>(start);
let lastSplit = start.splits[0].key;
let saved = $state(untrack(() => group)); let saved = $state(untrack(() => group));
let snapshot = JSON.stringify(start); let snapshot = JSON.stringify(start);
let errors = $state<Errors>({}); let errors = $state<Errors>({});
@@ -206,17 +203,9 @@
tick().then(() => focusField("description", false)); 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 { function currentKey(): number {
const key = splitOf(document.activeElement) ?? lastSplit; const holder = document.activeElement?.closest("[data-split]");
return form.splits.some((split) => split.key === key) return Number(holder?.getAttribute("data-split") ?? form.splits[0].key);
? key
: form.splits[0].key;
} }
function focusField(field: string, select = true) { function focusField(field: string, select = true) {
@@ -462,25 +451,29 @@
}, },
{ {
group: "Editor", group: "Editor",
keys: "X", input: true,
keys: "mod+alt+Backspace",
label: "Remove the current split", label: "Remove the current split",
run: () => removeSplit(), run: () => removeSplit(),
}, },
...TX_TYPES.map((type, index) => ({ ...TX_TYPES.map((type, index) => ({
group: "Editor", group: "Editor",
keys: `${index + 1}`, input: true,
keys: `alt+${index + 1}`,
label: `Make it a ${type}`, label: `Make it a ${type}`,
run: () => setType(type), run: () => setType(type),
})), })),
...FIELDS.map(([keys, field, label]) => ({ ...FIELDS.map(([keys, field, label]) => ({
group: "Editor", group: "Editor",
input: true,
keys, keys,
label: `Go to ${label}`, label: `Go to ${label}`,
run: () => focusField(field), run: () => focusField(field),
})), })),
{ {
group: "Editor", group: "Editor",
keys: "T", input: true,
keys: "alt+i",
label: "Go to the split transaction title", label: "Go to the split transaction title",
run: () => document.getElementById("group-title")?.focus(), run: () => document.getElementById("group-title")?.focus(),
}, },
@@ -526,11 +519,7 @@
{/snippet} {/snippet}
</PageHeader> </PageHeader>
<div <div class="flex flex-col gap-4" bind:this={root}>
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 class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div <div
aria-label="Transaction type" aria-label="Transaction type"
@@ -550,7 +539,9 @@
type="button" type="button"
> >
{TYPE_LABEL[type]} {TYPE_LABEL[type]}
<span class="kbd hidden lg:inline-flex">{index + 1}</span> <span class="kbd hidden lg:inline-flex"
>{prettyKeys(`alt+${index + 1}`)[0]}</span
>
</button> </button>
{/each} {/each}
</div> </div>
@@ -622,7 +613,7 @@
<Field <Field
error={errors.group_title?.[0]} error={errors.group_title?.[0]}
for="group-title" for="group-title"
keys="T" keys="alt+i"
label="Description of the split transaction" label="Description of the split transaction"
> >
<input <input
@@ -635,29 +626,20 @@
</Box> </Box>
{/if} {/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 <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)]" 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 <button
class="h-9 px-3.5 text-[13px] sm:h-cladd-md" 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"
color="brand"
disabled={saving} disabled={saving}
onclick={save} onclick={save}
variant="fill" type="button"
> >
{editing ? "Update transaction" : "Save transaction"} {editing ? "Update transaction" : "Save transaction"}
<span <span class="kbd hidden sm:inline-flex"
class="kbd hidden bg-white/20! text-current! shadow-none! sm:inline-flex"
>{prettyKeys("mod+Enter")[0]}</span >{prettyKeys("mod+Enter")[0]}</span
> >
</Button> </button>
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft"> <label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
<span class="hidden sm:inline">After saving</span> <span class="hidden sm:inline">After saving</span>
<select <select
@@ -703,4 +685,11 @@
</p> </p>
{/if} {/if}
</div> </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> </div>
@@ -10,21 +10,18 @@
id, id,
invalid = false, invalid = false,
placeholder = "", placeholder = "",
onpick,
}: { }: {
value: Ref; value: Ref;
search: (query: string) => Option[]; search: (query: string) => Option[];
id: string; id: string;
invalid?: boolean; invalid?: boolean;
placeholder?: string; placeholder?: string;
onpick?: (option: Option) => void;
} = $props(); } = $props();
</script> </script>
<AutocompleteInput <AutocompleteInput
{id} {id}
{invalid} {invalid}
{onpick}
openOnFocus openOnFocus
{placeholder} {placeholder}
{search} {search}
+14 -16
View File
@@ -8,6 +8,7 @@
import Field from "#lib/components/inputs/Field.svelte"; import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js"; import { INPUT } from "#lib/components/inputs/styles.js";
import TagsInput from "#lib/components/inputs/TagsInput.svelte"; import TagsInput from "#lib/components/inputs/TagsInput.svelte";
import { prettyKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
import AccountField from "./AccountField.svelte"; import AccountField from "./AccountField.svelte";
import type { Catalog } from "./catalog.svelte.js"; import type { Catalog } from "./catalog.svelte.js";
@@ -204,7 +205,9 @@
> >
<Trash class="size-3.5" /> <Trash class="size-3.5" />
Remove split Remove split
<span class="kbd hidden lg:inline-flex">X</span> <span class="kbd hidden lg:inline-flex"
>{prettyKeys("mod+alt+Backspace")[0]}</span
>
</button> </button>
{/if} {/if}
{/snippet} {/snippet}
@@ -213,7 +216,7 @@
<Field <Field
error={err("description")} error={err("description")}
for={id("description")} for={id("description")}
keys={keyHint("d")} keys={keyHint("alt+d")}
label="Description" label="Description"
> >
<AutocompleteInput <AutocompleteInput
@@ -226,10 +229,10 @@
bind:value={split.description} bind:value={split.description}
/> />
</Field> </Field>
{@render account("source", "Source account", "f")} {@render account("source", "Source account", "alt+f")}
{@render account("destination", "Destination account", "t")} {@render account("destination", "Destination account", "alt+t")}
{#if first} {#if first}
<Field error={err("date")} for={id("date")} keys="w" label="Date"> <Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
<DateInput <DateInput
id={id("date")} id={id("date")}
invalid={Boolean(err("date"))} invalid={Boolean(err("date"))}
@@ -242,7 +245,7 @@
<Field <Field
error={err("amount")} error={err("amount")}
for={id("amount")} for={id("amount")}
keys={keyHint("m")} keys={keyHint("alt+m")}
label="Amount" label="Amount"
> >
<AmountInput <AmountInput
@@ -325,7 +328,7 @@
<Field <Field
error={err("budget_id")} error={err("budget_id")}
for={id("budget")} for={id("budget")}
keys={keyHint("b")} keys={keyHint("alt+b")}
label="Budget" label="Budget"
> >
<RefField <RefField
@@ -340,7 +343,7 @@
<Field <Field
error={err("category_name")} error={err("category_name")}
for={id("category")} for={id("category")}
keys={keyHint("c")} keys={keyHint("alt+k")}
label="Category" label="Category"
> >
<AutocompleteInput <AutocompleteInput
@@ -382,7 +385,7 @@
<Field <Field
error={err("tags")} error={err("tags")}
for={id("tags")} for={id("tags")}
keys={keyHint("#")} keys={keyHint("alt+g")}
label="Tags" label="Tags"
> >
<TagsInput <TagsInput
@@ -393,12 +396,7 @@
/> />
</Field> </Field>
{#if withdrawal} {#if withdrawal}
<Field <Field error={err("bill_id")} for={id("bill")} label="Subscription">
error={err("bill_id")}
for={id("bill")}
keys={keyHint("s")}
label="Subscription"
>
<RefField <RefField
id={id("bill")} id={id("bill")}
invalid={Boolean(err("bill_id"))} invalid={Boolean(err("bill_id"))}
@@ -411,7 +409,7 @@
<Field <Field
error={err("notes")} error={err("notes")}
for={id("notes")} for={id("notes")}
keys={keyHint("r")} keys={keyHint("alt+r")}
label="Notes" label="Notes"
> >
<textarea <textarea
@@ -8,7 +8,6 @@ import type {
Page, Page,
TransactionGroup, TransactionGroup,
} from "#lib/api/types.js"; } from "#lib/api/types.js";
import { learnedBudget } from "#lib/budgets/map.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte"; import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
import { formatMoney } from "#lib/format.js"; import { formatMoney } from "#lib/format.js";
import { fuzzyFilter } from "#lib/fuzzy.js"; import { fuzzyFilter } from "#lib/fuzzy.js";
@@ -235,24 +234,8 @@ export class Catalog {
.catch(() => null); .catch(() => null);
this.history.set(key, found); 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;
} }
return found.then(
(past) =>
past && {
split: {
...past.split,
budget_id: budget.id,
budget_name: budget.attributes.name,
},
}
);
}
async fromJournal(journalId: string): Promise<Recent | null> { async fromJournal(journalId: string): Promise<Recent | null> {
const group = await api<One<TransactionGroup>>( const group = await api<One<TransactionGroup>>(
+3 -8
View File
@@ -364,20 +364,15 @@ function splitPayload(form: Form, split: SplitForm, update: boolean) {
return body; return body;
} }
const foreign = hasForeign(split) && split.foreignAmount.trim() !== ""; const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
let foreignAmount = update ? "0" : null;
if (foreign) {
foreignAmount = amountString(
split.foreignAmount,
split.foreignCurrency?.decimals ?? 2
);
}
return { return {
...body, ...body,
...accountFields("source", sideOf(form, split, "source")), ...accountFields("source", sideOf(form, split, "source")),
...accountFields("destination", sideOf(form, split, "destination")), ...accountFields("destination", sideOf(form, split, "destination")),
amount: amountString(split.amount, split.currency?.decimals ?? 2), amount: amountString(split.amount, split.currency?.decimals ?? 2),
currency_code: split.currency?.code, currency_code: split.currency?.code,
foreign_amount: foreignAmount, foreign_amount: foreign
? amountString(split.foreignAmount, split.foreignCurrency?.decimals ?? 2)
: null,
foreign_currency_code: foreign ? split.foreignCurrency?.code : null, foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
}; };
} }
+39 -198
View File
@@ -1,11 +1,8 @@
import type { Split } from "#lib/api/types.js"; import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js"; import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js"; import { today } from "#lib/dates.js";
import { session } from "#lib/session.svelte.js";
import type { Catalog } from "./catalog.svelte.js"; import type { Catalog } from "./catalog.svelte.js";
import { import {
type AccountRef,
type Cur,
emptyForm, emptyForm,
KINDS, KINDS,
OWN_SIDE, OWN_SIDE,
@@ -16,9 +13,9 @@ import {
toAccountRef, toAccountRef,
} from "./model.js"; } 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 MARKERS = new Set<string>([...MULTI, "#", "~"]);
const WORD = /\S+/g; const WORD = /\S+/g;
const NUMERIC = /^[\d(]/; const NUMERIC = /^[\d(]/;
@@ -30,109 +27,21 @@ export interface Segment {
value: string; value: string;
} }
export interface Equal {
code: string;
text: string;
}
export interface Quick { export interface Quick {
amount: number | null; amount: number | null;
amountText: string; amountText: string;
currency: string;
description: string; description: string;
equal: Equal | null;
segments: Segment[]; segments: Segment[];
type: TxType; type: TxType;
} }
const enabledCodes = () => export function parseQuick(input: string): Quick {
new Set( const segments: Segment[] = [];
session.currencies const words: string[] = [];
.filter((item) => item.attributes.enabled) let amount: number | null = null;
.map((item) => item.attributes.code) let amountText = "";
);
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 deposit = false;
let current: Segment | null = null;
let first = true; let first = true;
for (const match of input.matchAll(WORD)) { for (const match of input.matchAll(WORD)) {
let [word] = match; let [word] = match;
@@ -143,32 +52,42 @@ export function parseQuick(input: string, codes = enabledCodes()): Quick {
start += 1; start += 1;
} }
first = false; first = false;
const taken = if (!word) {
!word || continue;
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;
} }
const hasTarget = state.segments.some((segment) => segment.marker === ">"); 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 === ">");
let type: TxType = "withdrawal"; let type: TxType = "withdrawal";
if (hasTarget) { if (hasTarget) {
type = "transfer"; type = "transfer";
} else if (deposit) { } else if (deposit) {
type = "deposit"; type = "deposit";
} }
return { return { amount, amountText, description: words.join(" "), segments, type };
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) => export const markerValue = (quick: Quick, marker: Marker) =>
@@ -239,77 +158,6 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
: [`No asset account or liability called "${ownName}"`]; : [`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( function resolveLinks(
quick: Quick, quick: Quick,
catalog: Catalog, catalog: Catalog,
@@ -351,17 +199,10 @@ export function resolveQuick(
split, split,
counterparty ? recent[counterparty] : null counterparty ? recent[counterparty] : null
); );
problems.push( problems.push(...links.problems);
...links.problems,
...resolveBill(quick, catalog, split),
...resolveMoney(
quick,
split,
split[own],
quick.type === "transfer" ? split[other] : null
)
);
split.tags = tagsOf(quick); split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
split.description = split.description =
quick.description || quick.description ||
split[other].name || split[other].name ||
+16 -13
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Money from "#lib/components/Money.svelte"; import Amount from "#lib/components/Amount.svelte";
import { formatMoney } from "#lib/format.js"; import { formatMoney } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import type { Total } from "./object-groups.js"; import type { Total } from "./object-groups.js";
let { let {
@@ -13,20 +12,24 @@
tone?: "income" | "expense" | "plain"; tone?: "income" | "expense" | "plain";
empty?: string; empty?: string;
} = $props(); } = $props();
const merged = $derived(display.total(totals) !== null);
</script> </script>
{#if totals.length === 0} {#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 empty} {#if empty}
<span class="text-cladd-fg-softer">{empty}</span> <span class="text-cladd-fg-softer">{empty}</span>
{/if} {/if}
{: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} {/each}
{/if}
+11 -37
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Calendar from "@lucide/svelte/icons/calendar"; import Calendar from "@lucide/svelte/icons/calendar";
import { tick, untrack } from "svelte"; import { untrack } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements"; import type { HTMLInputAttributes } from "svelte/elements";
import { addDays, today } from "#lib/dates.js"; import { addDays, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js"; import { formatDate } from "#lib/format.js";
@@ -11,21 +11,16 @@
value = $bindable(""), value = $bindable(""),
ref = $bindable(null), ref = $bindable(null),
invalid = false, invalid = false,
compact = false,
oncommit,
class: className = "", class: className = "",
...rest ...rest
}: Omit<HTMLInputAttributes, "value"> & { }: Omit<HTMLInputAttributes, "value"> & {
value?: string; value?: string;
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
invalid?: boolean; invalid?: boolean;
compact?: boolean;
oncommit?: (value: string) => void;
} = $props(); } = $props();
let text = $state(value); let text = $state(value);
let picker = $state<HTMLInputElement | null>(null); let picker = $state<HTMLInputElement | null>(null);
let focused = $state(false);
const parsed = $derived(parseDate(text, today())); const parsed = $derived(parseDate(text, today()));
const shown = $derived(parsed ?? value); const shown = $derived(parsed ?? value);
@@ -34,9 +29,6 @@
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}` ? `${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(() => { $effect(() => {
const next = value; const next = value;
@@ -55,33 +47,14 @@
} }
} }
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() { function handleBlur() {
focused = false;
if (parsed) { if (parsed) {
commit(parsed); text = parsed;
value = parsed;
} }
} }
function handleKey(event: KeyboardEvent) { function handleKey(event: KeyboardEvent) {
if (event.key === "Enter" && parsed) {
commit(parsed);
}
if ( if (
(event.key === "ArrowUp" || event.key === "ArrowDown") && (event.key === "ArrowUp" || event.key === "ArrowDown") &&
!(event.altKey || event.ctrlKey || event.metaKey) !(event.altKey || event.ctrlKey || event.metaKey)
@@ -89,7 +62,8 @@
event.preventDefault(); event.preventDefault();
const step = const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1); (event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
commit(addDays(parsed ?? value ?? today(), step)); value = addDays(parsed ?? value ?? today(), step);
text = value;
} }
} }
@@ -104,20 +78,17 @@
{...rest} {...rest}
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined} aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
autocomplete="off" autocomplete="off"
class="{INPUT} num {compact ? "pr-8" : "pr-28"}" class="{INPUT} num pr-28"
onblur={handleBlur} onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput} oninput={handleInput}
onkeydown={handleKey} onkeydown={handleKey}
spellcheck="false" spellcheck="false"
value={display} value={text}
> >
{#if !compact}
<span <span
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft" class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
>{label}</span >{label}</span
> >
{/if}
<button <button
aria-label="Pick a date" 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" class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
@@ -130,7 +101,10 @@
<input <input
aria-hidden="true" aria-hidden="true"
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0" class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
onchange={({ currentTarget }) => commit(currentTarget.value)} onchange={({ currentTarget }) => {
({ value } = currentTarget);
text = value;
}}
tabindex="-1" tabindex="-1"
type="date" type="date"
{value} {value}
+1 -1
View File
@@ -10,5 +10,5 @@ export function setQuery(values: Record<string, string | null | undefined>) {
url.searchParams.delete(key); url.searchParams.delete(key);
} }
} }
return goto(url, { replace: true, reset: false, state: page.state }); return goto(url, { replace: true, state: page.state });
} }
@@ -30,7 +30,6 @@
const action = new Action(); const action = new Action();
const values = $state<Record<string, string>>({}); const values = $state<Record<string, string>>({});
const fields: Record<string, HTMLElement> = {};
const links = $derived( const links = $derived(
(piggy.attributes.accounts ?? []).map((link) => { (piggy.attributes.accounts ?? []).map((link) => {
@@ -51,10 +50,9 @@
} }
} }
function fail(message: string, id = open[0]?.id ?? "") { function fail(message: string) {
action.failed = true; action.failed = true;
action.message = message; action.message = message;
fields[id]?.focus();
} }
async function save() { async function save() {
@@ -66,11 +64,11 @@
} }
const amount = evalAmount(raw); const amount = evalAmount(raw);
if (amount === null || amount <= 0) { if (amount === null || amount <= 0) {
return fail("Enter an amount above zero", link.id); return fail("Enter an amount above zero");
} }
if (amount > link.max + 1e-9) { if (amount > link.max + 1e-9) {
const max = formatMoney(piggyMoney(piggy.attributes, link.max)); const max = formatMoney(piggyMoney(piggy.attributes, link.max));
return fail(`${link.name} allows at most ${max}`, link.id); return fail(`${link.name} allows at most ${max}`);
} }
deltas[link.id] = mode === "add" ? amount : -amount; deltas[link.id] = mode === "add" ? amount : -amount;
} }
@@ -111,7 +109,6 @@
onkeydown={onEscape} onkeydown={onEscape}
bind:value={values[link.id]} bind:value={values[link.id]}
{@attach (node) => { {@attach (node) => {
fields[link.id] = node;
if (i === 0) { if (i === 0) {
node.focus(); node.focus();
} }
@@ -119,11 +116,7 @@
/> />
<span class="text-[12px] text-cladd-fg-soft"> <span class="text-[12px] text-cladd-fg-soft">
up to up to
<Amount <Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
{...piggyMoney(piggy.attributes, link.max)}
exact
tone="neutral"
/>
</span> </span>
</div> </div>
{:else} {:else}
-93
View File
@@ -1,93 +0,0 @@
<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
@@ -1,235 +0,0 @@
<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}
@@ -1,382 +0,0 @@
<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
@@ -1,59 +0,0 @@
<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
@@ -1,187 +0,0 @@
<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
@@ -1,159 +0,0 @@
<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
@@ -1,76 +0,0 @@
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
@@ -1,92 +0,0 @@
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
@@ -1,67 +0,0 @@
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
@@ -1,171 +0,0 @@
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,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { displayCode, inDisplay } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js"; import { reportQueries } from "#lib/reports/queries.js";
import { import {
displayMoney,
drillQuery, drillQuery,
money, money,
navigate, navigate,
@@ -36,15 +34,12 @@
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
}); });
const sums = $derived.by(() => { const sums = $derived.by(() => {
const into = displayCode(new Set(rows.map((row) => row.code)));
const map = new Map<string, { start: number; end: number }>(); const map = new Map<string, { start: number; end: number }>();
for (const row of rows) { for (const row of rows) {
const start = inDisplay(row.start, row.code, into); const sum = map.get(row.code) ?? { end: 0, start: 0 };
const end = inDisplay(row.end, row.code, into); sum.start += row.start;
const sum = map.get(start.code) ?? { end: 0, start: 0 }; sum.end += row.end;
sum.start += start.amount; map.set(row.code, sum);
sum.end += end.amount;
map.set(start.code, sum);
} }
const order = report.codes; const order = report.codes;
return [...map.entries()].sort( return [...map.entries()].sort(
@@ -78,17 +73,17 @@
title="Account balances" title="Account balances"
> >
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full min-w-[34rem] border-collapse text-[13px]"> <table class="w-full border-collapse text-[13px]">
<thead> <thead>
<tr class="text-[12px] text-cladd-fg-soft"> <tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 w-full px-3 text-left font-medium">Name</th> <th class="h-7 px-3 text-left font-medium">Name</th>
<th class="h-7 w-32 px-3 text-right font-medium"> <th class="h-7 px-3 text-right font-medium">
Balance at start of period Balance at start of period
</th> </th>
<th class="h-7 w-32 px-3 text-right font-medium"> <th class="h-7 px-3 text-right font-medium">
Balance at end of period Balance at end of period
</th> </th>
<th class="h-7 w-32 px-3 text-right font-medium">Difference</th> <th class="h-7 px-3 text-right font-medium">Difference</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -100,7 +95,7 @@
? "bg-row-selected" ? "bg-row-selected"
: ""}" : ""}"
> >
<td class="max-w-0 px-3"> <td class="max-w-[16rem] px-3">
<button <button
aria-pressed={active} aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline" class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
@@ -111,14 +106,10 @@
{row.name} {row.name}
</button> </button>
</td> </td>
<td class="num px-3 text-right"> <td class="num px-3 text-right">{money(row.code, row.start)}</td>
{displayMoney(row.code, row.start)} <td class="num px-3 text-right">{money(row.code, row.end)}</td>
</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)}"> <td class="num px-3 text-right {tone(row.end - row.start)}">
{displayMoney(row.code, row.end - row.start, true)} {money(row.code, row.end - row.start, true)}
</td> </td>
</tr> </tr>
{/each} {/each}
@@ -0,0 +1,246 @@
<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>
@@ -0,0 +1,73 @@
<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,7 +4,6 @@
import LineChart from "#lib/components/charts/LineChart.svelte"; import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js"; import { contains, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js"; import { formatDate } from "#lib/format.js";
import { displayCode, inDisplay, shownCode } from "#lib/reports/display.js";
import { reportQueries } from "#lib/reports/queries.js"; import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js"; import { money, useReport } from "#lib/reports/report.svelte.js";
import { modeAxis } from "#lib/reports/view.js"; import { modeAxis } from "#lib/reports/view.js";
@@ -32,16 +31,11 @@
const charts = $derived.by(() => { const charts = $derived.by(() => {
const byCode = new Map<string, LineSeries[]>(); const byCode = new Map<string, LineSeries[]>();
const sets = chart.data ?? []; for (const set of chart.data ?? []) {
const into = displayCode( const code = set.currency_code ?? "";
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]) => ({ const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
x: x.slice(0, 10), x: x.slice(0, 10),
y: inDisplay(Number(y), from, into).amount, y: Number(y),
})); }));
const list = byCode.get(code) ?? []; const list = byCode.get(code) ?? [];
if (net && list.length > 0) { if (net && list.length > 0) {
-461
View File
@@ -1,461 +0,0 @@
<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>
@@ -1,145 +0,0 @@
<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>
+14 -26
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { formatRange, percent } from "#lib/format.js"; import { formatRange, percent } from "#lib/format.js";
import { inDisplay } from "#lib/reports/display.js";
import { NONE } from "#lib/reports/flows.js"; import { NONE } from "#lib/reports/flows.js";
import { reportQueries } from "#lib/reports/queries.js"; import { reportQueries } from "#lib/reports/queries.js";
import { import {
@@ -37,31 +36,12 @@
const withdrawals = flows.filter( const withdrawals = flows.filter(
(f) => f.dir === -1 && f.type === "withdrawal" (f) => f.dir === -1 && f.type === "withdrawal"
); );
const merged = new Map<string, Line>(); const result: Line[] = limits.data.map((item) => {
for (const item of limits.data) {
const a = item.attributes; const a = item.attributes;
const start = (a.start ?? p.start).slice(0, 10); const start = (a.start ?? p.start).slice(0, 10);
const end = (a.end ?? p.end).slice(0, 10); const end = (a.end ?? p.end).slice(0, 10);
const { amount, code } = inDisplay( const code = a.currency_code ?? "";
Number(a.amount ?? 0), const spent = withdrawals
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( .filter(
(f) => (f) =>
f.budget?.id === String(a.budget_id) && f.budget?.id === String(a.budget_id) &&
@@ -69,11 +49,19 @@
f.date >= start && f.date >= start &&
f.date <= end f.date <= end
) )
.reduce((sum, f) => sum + f.amount, 0), .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,
start, start,
};
}); });
}
const result = [...merged.values()];
result.sort( result.sort(
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start) (a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
); );
@@ -1,26 +0,0 @@
<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}
@@ -1,199 +0,0 @@
<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
@@ -1,128 +0,0 @@
<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,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { formatRange } from "#lib/format.js"; import { formatRange } from "#lib/format.js";
import { displayFlows } from "#lib/reports/display.js";
import { import {
currencies, currencies,
measureOf, measureOf,
@@ -29,13 +28,10 @@
const before = $derived( const before = $derived(
earlier.data && report.accountsReady earlier.data && report.accountsReady
? total( ? total(
displayFlows(
toFlows( toFlows(
earlier.data, earlier.data,
new Set(report.selected), new Set(report.selected),
(id) => report.byId().get(id)?.currency_code (id) => report.byId().get(id)?.currency_code
),
report.into
) )
) )
: undefined : undefined
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more