feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days

The right column drops the box per account for one table per currency
with sparklines and the last five transactions on l, a list of what was
entered since the last look (x marks reviewed), bills and piggy bank
dates in the next seven days, and debts with friends net per currency.
The left column opens with spending so far against a typical month, and
$ shows totals in UAH.
This commit is contained in:
hh committed 2026-10-16 15:14:03 +00:00
1 parent cea51281cd
commit 8c4e72666b
11 files changed
+1452 -312

No files matched your search

+121 -62
View File
@@ -1,18 +1,25 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
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 BarChart from "#lib/components/charts/BarChart.svelte";
import { tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import {
formatPrimary,
inPrimaryTotal,
ratesQuery,
} from "#lib/dashboard/primary.svelte.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { accountsQuery, byCurrency, money } from "./data.js";
import { goto } from "$app/navigation";
import { accountsQuery, byCurrency } from "./data.js";
const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end),
placeholderData: keepPreviousData,
}));
const rates = createQuery(() => ratesQuery());
const groups = $derived(
byCurrency(
(liabilities.data ?? []).filter(
@@ -23,83 +30,135 @@
(a) => a.attributes.currency_code ?? ""
).map(([code, list]) => {
const first = list[0].attributes;
const format = money({
const cur = {
code,
decimals: first.currency_decimal_places ?? 2,
symbol: first.currency_symbol ?? code,
});
const bars = list
};
const rows = list
.map((a) => {
const due = Math.abs(toNumber(a.attributes.debt_amount));
const value =
a.attributes.liability_direction === "credit" ? due : -due;
return {
color: value > 0 ? tones.income : tones.expense,
href: showUrl(a.id),
key: a.id,
label: a.attributes.name,
value,
id: a.id,
key: `debt-${a.id}`,
name: a.attributes.name,
value: a.attributes.liability_direction === "credit" ? due : -due,
};
})
.sort((x, y) => y.value - x.value);
const owed = bars
.filter((b) => b.value > 0)
.reduce((s, b) => s + b.value, 0);
const owe = bars
.filter((b) => b.value < 0)
.reduce((s, b) => s - b.value, 0);
return { bars, code, format, owe, owed };
return {
code,
cur,
largest: Math.max(...rows.map((r) => Math.abs(r.value))),
net: rows.reduce((s, r) => s + r.value, 0),
rows,
};
})
);
const nets = $derived.by(() => {
const total = inPrimaryTotal(
groups.map((g) => ({ amount: g.net, code: g.code })),
rates.data
);
if (total === null) {
return groups.map((g) => ({
code: g.code,
net: g.net,
text: formatMoney({ amount: Math.abs(g.net), ...g.cur }),
}));
}
return [{ code: "all", net: total, text: formatPrimary(Math.abs(total)) }];
});
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>
{#snippet more()}
<a
class="text-brand hover:underline dark:text-(--ff-series-1)"
href="/accounts/liabilities"
>See your liabilities</a
>
{/snippet}
{#if groups.length > 0}
<Box href="/accounts/liabilities" title="Debts with friends">
<Box
body="p-0"
footer={more}
href="/accounts/liabilities"
title="Debts with friends"
>
<div
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 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
{#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
>
{#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>
{g.net > 0 ? "owed to me" : "I owe"}</span
>
{/if}
{/each}
</div>
{#snippet footer()}
<a
class="text-brand dark:text-(--ff-series-1) hover:underline"
href="/accounts/liabilities"
>See your liabilities</a
>
{/snippet}
<table class="w-full border-collapse text-[13px]">
{#each groups as g (g.code)}
<tbody class="border-cladd-outline border-t">
{#each g.rows as r, i (r.key)}
<tr
class="h-7 {i > 0
? "border-cladd-outline/60 border-t"
: ""} {column.rowClass(r.key)}"
id={column.id(r.key)}
onclick={() => column.point(r.key)}
>
<td class="w-full max-w-0 pl-3">
<a
class="block truncate text-cladd-fg hover:underline"
href={showUrl(r.id)}
title={r.name}
>{r.name}</a
>
</td>
<td class="px-2">
<div
class="flex h-1.5 w-16 overflow-hidden rounded-full bg-cladd-surface-next"
>
<span
class="h-full rounded-full {r.value > 0
? "bg-income"
: "bg-expense"}"
style:width="{(Math.abs(r.value) / g.largest) * 100}%"
></span>
</div>
</td>
<td
class="num whitespace-nowrap pr-3 pl-2 text-right font-medium {r.value >
0
? "text-income"
: "text-expense"}"
>
{formatMoney({ amount: r.value, ...g.cur }, true)}
</td>
</tr>
{/each}
</tbody>
{/each}
</table>
</Box>
{/if}