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

+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import {
type Action,
type Actions,
averageDays,
type Movement,
months,
overpaid,
owed,
stance,
} from "#lib/accounts/ledger.js";
import type { Account, Item } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { daysBetween, today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
import { showUrl as transactionUrl } from "#lib/transactions/split.js";
import { showUrl } from "./links.js";
import MonthBars from "./MonthBars.svelte";
const BUTTON =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
const PRIMARY = `${BUTTON} cladd-color-green text-cladd-primary`;
let {
item,
list,
actions,
keys,
rows = 5,
named = true,
current = false,
id,
onfocusin,
}: {
item: Item<Account>;
list: Movement[] | undefined;
actions: Actions | null;
keys: Record<keyof Actions, string>;
rows?: number;
named?: boolean;
current?: boolean;
id?: string;
onfocusin?: () => void;
} = $props();
const a = $derived(item.attributes);
const credit = $derived(a.liability_direction !== "debit");
const side = $derived(stance(a));
const amount = $derived(Math.abs(owed(a)));
const cur = $derived({
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
const format = (value: number) => formatMoney({ ...cur, amount: value });
const bars = $derived(list ? months(list) : []);
const sent = $derived(bars.reduce((s, m) => s + m.sent, 0));
const received = $derived(bars.reduce((s, m) => s + m.received, 0));
const average = $derived(list ? averageDays(list, credit) : null);
const last = $derived(list?.[0]);
const shown = $derived(list?.slice(0, rows) ?? []);
function ago(date: string) {
const days = daysBetween(date, today());
if (days === 0) {
return "today";
}
return days === 1 ? "yesterday" : `${days} days ago`;
}
</script>
{#snippet button(
action: Action,
key: string,
primary = false
)}
<a
aria-label={action.hint}
class={primary ? PRIMARY : BUTTON}
href={action.href}
>{action.label} <span class="kbd">{key}</span></a
>
{/snippet}
<article
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
? "outline-2 outline-(--ff-brand)"
: ""}"
{id}
{onfocusin}
>
<div class="flex flex-1 flex-col gap-3 p-3">
<div>
{#if named}
<h3 class="truncate font-semibold text-[15px] leading-snug">
<a class="hover:underline" href={showUrl(item.id)} title={a.name}
>{a.name}</a
>
</h3>
{/if}
{#if side === "settled"}
<p class="font-semibold text-[28px] text-cladd-fg-soft leading-tight">
Settled
</p>
{:else}
<p class="flex flex-wrap items-baseline gap-x-2">
<span class="text-[15px] text-cladd-fg-soft"
>{side === "owed" ? "Owes you" : "You owe"}</span
>
<span
class="num font-semibold text-[28px] leading-tight {side === "owed"
? "text-income"
: "text-expense"}"
>{format(amount)}</span
>
</p>
{/if}
{#if overpaid(a)}
<p class="text-[13px] text-cladd-fg-soft">
{credit ? "Paid you back" : "You paid back"}
<span class="num">{format(amount)}</span>
more than borrowed
</p>
{/if}
</div>
{#if last}
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
>
<dt class="text-cladd-fg-soft">Last movement</dt>
<dd class="text-right">{formatDate(last.date)}, {ago(last.date)}</dd>
{#if average === null}
<dt class="col-span-2 text-cladd-fg-soft">No repayments yet</dt>
{:else}
<dt class="text-cladd-fg-soft">
{credit ? "Usually pays back in" : "You usually pay back in"}
</dt>
<dd class="text-right">
{average}
{average === 1 ? "day" : "days"}
</dd>
{/if}
{#if received > 0}
<dt class="text-cladd-fg-soft">Received in 12 months</dt>
<dd class="num text-right text-income">{format(received)}</dd>
{/if}
{#if sent > 0}
<dt class="text-cladd-fg-soft">Sent in 12 months</dt>
<dd class="num text-right text-expense">{format(sent)}</dd>
{/if}
</dl>
{/if}
{#if received + sent > 0}
<MonthBars {format} months={bars} />
{/if}
{#if rows > 0}
<table class="w-full table-fixed border-collapse text-[13px]">
<tbody>
{#each shown as m, i (`${m.group}-${i}`)}
<tr class="h-7 border-cladd-outline/60 border-t">
<td class="w-16 whitespace-nowrap text-cladd-fg-soft">
{formatDate(m.date, "short")}
</td>
<td class="truncate">
<a
class="hover:underline"
href={transactionUrl(m.group)}
title={m.description}
>{m.opening ? "Opening balance" : m.description}</a
>
</td>
<td class="w-28 whitespace-nowrap text-right">
<Amount
{...cur}
amount={m.into ? -m.amount : m.amount}
exact
signed
/>
</td>
</tr>
{:else}
<tr class="h-7 border-cladd-outline/60 border-t">
<td class="text-cladd-fg-soft" colspan="3">
{list ? "No movements yet" : ""}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if actions}
<footer
class="flex flex-col gap-1.5 border-cladd-outline border-t px-3 py-2"
>
<div class="flex flex-wrap items-center gap-1.5">
{#if actions.repay}
{@render button(actions.repay, keys.repay, true)}
{/if}
{@render button(actions.more, keys.more)}
{#if actions.offset}
{@render button(actions.offset, keys.offset)}
{/if}
</div>
{#if actions.offset?.line}
<p class="text-[12px] text-cladd-fg-soft">{actions.offset.line}</p>
{/if}
</footer>
{/if}
</article>