feat(accounts): debts in general words, actions named by what they record

Cards are titled by the account name with the position on its own line:
owes you, you owe, settled, and paid back more than borrowed when a debt
crossed zero against its direction, so one account per person covers
both directions. Actions read Record repayment, Lend more, Pay back,
Borrow more and Offset against another debt, which says what it moves.
No more friends in the copy; the dashboard box is Debts.
This commit is contained in:
hh committed 2026-10-16 18:22:01 +00:00
1 parent 4936258f32
commit 54895b9305
11 files changed
+352 -336

No files matched your search

+45 -74
View File
@@ -2,10 +2,13 @@
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import {
type Action,
actionsFor,
isFriend,
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";
@@ -17,13 +20,14 @@
} 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, formatMoney, toNumber } from "#lib/format.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 FriendCard from "./FriendCard.svelte";
import DebtCard from "./DebtCard.svelte";
import {
editUrl,
LIABILITY_LABEL,
@@ -32,7 +36,9 @@
TYPE_ONE,
} from "./links.js";
const KEYS = { lend: "+", offset: "o", settle: "s" };
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(() => ({
@@ -53,9 +59,9 @@
}));
const assets = createQuery(() => lists.accounts("asset"));
const debts = $derived((liabilities.data ?? []).filter(isFriend));
const debts = $derived((liabilities.data ?? []).filter(isDebt));
const others = $derived(
(liabilities.data ?? []).filter((item) => !isFriend(item))
(liabilities.data ?? []).filter((item) => !isDebt(item))
);
const history = createQuery(() => ({
enabled: debts.length > 0,
@@ -79,26 +85,20 @@
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
}));
const rank = (item: Item<Account>) => {
const position = owed(item.attributes);
if (position > 0) {
return 0;
}
return position < 0 ? 1 : 2;
};
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
const lastDate = (item: Item<Account>) =>
history.data?.get(item.id)?.[0]?.date ?? "";
const friends = $derived(
const cards = $derived(
debts.toSorted(
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
)
);
const items = $derived([...friends, ...others]);
const items = $derived([...cards, ...others]);
const index = $derived(new Map(items.map((item, i) => [item.id, i])));
const plans = $derived(
new Map(
friends.map((item) => [
cards.map((item) => [
item.id,
history.data && assets.data
? actionsFor(
@@ -140,25 +140,26 @@
}
return [...map.values()];
});
const owedToMe = $derived(sums.filter((s) => s.owed > 0));
const iOwe = $derived(sums.filter((s) => s.owe > 0));
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 act(kind: "settle" | "lend" | "offset") {
const plan = plans.get(current()?.id ?? "");
const target = plan?.[kind];
if (target) {
goto(target.href);
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 name = current()?.attributes.name ?? "a friend";
const plan = plans.get(current()?.id ?? "");
return [
{
@@ -172,38 +173,9 @@
}
},
},
{
group: "Friends",
keys: "s",
label: plan?.settle?.label ?? `Settle up with ${name}`,
run: () => act("settle"),
},
{
group: "Friends",
hidden: true,
keys: "S",
label: "Settle up",
run: () => act("settle"),
},
{
group: "Friends",
keys: "+",
label: plan?.lend.label ?? `Lend more to ${name}`,
run: () => act("lend"),
},
{
group: "Friends",
keys: "o",
label: plan?.offset.label ?? `Offset ${name} against another debt`,
run: () => act("offset"),
},
{
group: "Friends",
hidden: true,
keys: "=",
label: "Offset",
run: () => act("offset"),
},
...bind("repay", plan?.repay),
...bind("more", plan?.more),
...bind("offset", plan?.offset),
{
group: "Accounts",
keys: "c",
@@ -233,19 +205,18 @@
)}
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
{#each list as sum (sum.code)}
<span
class="num font-semibold text-[24px] leading-tight {key === "owed"
? "text-income"
: "text-expense"}"
>{formatMoney({ ...sum, amount: sum[key] })}</span
>
{:else}
<span
class="num font-semibold text-[24px] text-cladd-fg-soft leading-tight"
>0</span
>
{/each}
<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}
@@ -272,13 +243,13 @@
<section
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
>
{@render totals("Owed to me", owedToMe, "owed")}
{@render totals("I owe", iOwe, "owe")}
{@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 friends as item (item.id)}
{#each cards as item (item.id)}
{@const i = index.get(item.id) ?? -1}
<FriendCard
<DebtCard
actions={plans.get(item.id) ?? null}
current={nav.cursor === i}
id={nav.rowId(i)}
@@ -293,7 +264,7 @@
<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 with friends yet</p>
<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
>