feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups

This commit is contained in:
hh committed 2026-10-16 03:38:31 +00:00
1 parent 8b3f7e78a3
commit 4a693a83c9
27 files changed
+3492 -1

No files matched your search

+499
View File
@@ -0,0 +1,499 @@
<script lang="ts">
import Minus from "@lucide/svelte/icons/minus";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, PiggyBank } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { tones } from "#lib/components/charts/chart.js";
import Meter from "#lib/components/charts/Meter.svelte";
import {
byObjectGroup,
LINK,
sumByCurrency,
swapOrder,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import MoneyEditor from "#lib/components/piggy-banks/MoneyEditor.svelte";
import {
limit,
type Mode,
piggyMoney,
rooms,
} from "#lib/components/piggy-banks/piggy.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { formatDate, formatMoney, percent, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
const piggyQuery = lists.piggyBanks();
const piggies = createQuery(() => piggyQuery);
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const all = $derived(piggies.data ?? []);
const sections = $derived(byObjectGroup(all));
const rows = $derived(sections.flatMap((s) => s.items));
const offsets = $derived(
sections.map((_, i) =>
sections.slice(0, i).reduce((n, s) => n + s.items.length, 0)
)
);
const room = $derived(
rooms(all, [...(assets.data ?? []), ...(liabilities.data ?? [])])
);
let editing = $state<{ id: string; mode: Mode } | null>(null);
let menuFor = $state<Item<PiggyBank> | null>(null);
let armed = $state(false);
let failure = $state("");
const sum = (items: Item<PiggyBank>[], field: keyof PiggyBank) =>
sumByCurrency(
items
.filter((p) => toNumber(p.attributes[field] as string) > 0)
.map((p) => piggyMoney(p.attributes, p.attributes[field]))
);
const canAdd = (p: Item<PiggyBank>) =>
(p.attributes.accounts ?? []).some(
(a) =>
limit(
p.attributes,
a.account_id ?? "",
"add",
room.get(a.account_id ?? "")
) > 0
);
const canRemove = (p: Item<PiggyBank>) =>
toNumber(p.attributes.current_amount) > 0;
function startMoney(p: Item<PiggyBank> | undefined, mode: Mode): void {
if (p) {
nav.cursor = rows.indexOf(p);
editing = { id: p.id, mode };
}
}
async function update(p: Item<PiggyBank>, body: Record<string, unknown>) {
failure = "";
try {
await api(`/v1/piggy-banks/${p.id}`, { body, method: "PUT" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
function move(dir: 1 | -1): void {
const { current } = nav;
const section = sections.find((s) => current && s.items.includes(current));
if (!(current && section)) {
return;
}
const other = section.items[section.items.indexOf(current) + dir];
if (!other) {
return;
}
const order = swapOrder(piggyQuery.queryKey, current, other);
nav.move(nav.cursor + dir);
update(current, { order });
}
async function remove(p: Item<PiggyBank>) {
failure = "";
try {
await api(`/v1/piggy-banks/${p.id}`, { method: "DELETE" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
const nav: ListNav<Item<PiggyBank>> = new ListNav<Item<PiggyBank>>({
edit: (p) => goto(`/piggy-banks/edit/${p.id}`),
extra: () => [
{
group: "Piggy banks",
keys: "+",
label: "Add money",
run: () => startMoney(nav.current, "add"),
},
{
group: "Piggy banks",
hidden: true,
keys: "=",
label: "Add money",
run: () => startMoney(nav.current, "add"),
},
{
group: "Piggy banks",
keys: "-",
label: "Remove money",
run: () => startMoney(nav.current, "remove"),
},
{
group: "Piggy banks",
keys: "alt+j",
label: "Move down in group",
run: () => move(1),
},
{
group: "Piggy banks",
keys: "alt+k",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+˚",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Piggy banks",
keys: "c",
label: "New piggy bank",
run: () => goto("/piggy-banks/create"),
},
],
id: "piggies",
items: () => rows,
key: (p) => p.id,
open: (p) => goto(`/piggy-banks/show/${p.id}`),
});
nav.use();
const head = "px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
const cell = "px-3 py-1 align-middle";
const wide = "hidden md:table-cell";
const COLUMNS = 9;
</script>
<PageHeader crumbs={[{ label: "Piggy banks" }]} title="Piggy banks">
{#snippet actions()}
<a class={buttonVariants({ size: "sm" })} href="/piggy-banks/create">
<Plus />Create new piggy bank<span class="kbd">c</span>
</a>
{/snippet}
</PageHeader>
<Box body="p-0" title="Piggy banks">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
{/snippet}
{#if piggies.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading piggy banks</p>
{:else if piggies.error}
<p class="px-3 py-6 text-expense">{piggies.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-6 text-cladd-fg-soft">
No piggy banks yet.
<a class={LINK} href="/piggy-banks/create">Create a piggy bank</a>
</p>
{:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger>
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Piggy bank</th>
<th class="{head} text-right">Saved so far</th>
<th class={head} colspan="3"></th>
<th class="{head} {wide} text-right">Target amount</th>
<th class="{head} {wide} text-right">Left to save</th>
<th class="{head} hidden text-right lg:table-cell">
Suggested per month
</th>
<th class="{head} hidden lg:table-cell">Target date</th>
</tr>
</thead>
{#each sections as section, s (section.id)}
<tbody>
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60 font-semibold"
>
<td class={cell}>
{section.title}
<span class="ms-1 font-normal text-cladd-fg-softer"
>{section.items.length}</span
>
</td>
<td class="{cell} text-right">
<Totals
tone="income"
totals={sum(section.items, "current_amount")}
/>
</td>
<td colspan="3"></td>
<td class="{cell} {wide} text-right">
<Totals totals={sum(section.items, "target_amount")} />
</td>
<td class="{cell} {wide} text-right">
<Totals totals={sum(section.items, "left_to_save")} />
</td>
<td class="{cell} hidden text-right lg:table-cell">
<Totals totals={sum(section.items, "save_per_month")} />
</td>
<td class="hidden lg:table-cell"></td>
</tr>
{#each section.items as item, j (item.id)}
{@const i = offsets[s] + j}
{@const p = item.attributes}
{@const pct = p.percentage}
<tr
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = item;
nav.cursor = i;
}}
>
<td class={cell}>
<a class={LINK} href="/piggy-banks/show/{item.id}"
>{p.name}</a
>
</td>
<td class="{cell} text-right">
<Amount
{...piggyMoney(p, p.current_amount)}
tone="income"
/>
</td>
<td class="w-8 py-1 ps-2 text-right">
<button
aria-label="Remove money from {p.name}"
class={buttonVariants({
size: "icon-xs",
variant: "ghost",
})}
disabled={!canRemove(item)}
onclick={() => startMoney(item, "remove")}
title="Remove money (-)"
type="button"
>
<Minus />
</button>
</td>
<td class="w-24 py-1 sm:w-40">
{#if pct !== null && pct !== undefined}
<div class="flex items-center gap-2">
<Meter
label={percent(pct / 100)}
max={toNumber(p.target_amount)}
segments={[
{
color: pct >= 100 ? tones.income : undefined,
value: toNumber(p.current_amount),
},
]}
/>
<span
class="num w-9 text-right text-[12px] text-cladd-fg-soft"
>{percent(pct / 100)}</span
>
</div>
{/if}
</td>
<td class="w-8 py-1 pe-2">
<button
aria-label="Add money to {p.name}"
class={buttonVariants({
size: "icon-xs",
variant: "ghost",
})}
disabled={!canAdd(item)}
onclick={() => startMoney(item, "add")}
title="Add money (+)"
type="button"
>
<Plus />
</button>
</td>
<td class="{cell} {wide} text-right">
{#if toNumber(p.target_amount) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.target_amount))}</span
>
{/if}
</td>
<td class="{cell} {wide} text-right">
{#if toNumber(p.left_to_save) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.left_to_save))}</span
>
{/if}
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if p.target_date && toNumber(p.save_per_month) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.save_per_month))}</span
>
{/if}
</td>
<td class="{cell} num hidden lg:table-cell">
{formatDate(p.target_date)}
</td>
</tr>
{#if editing?.id === item.id}
<tr class="border-cladd-outline/60 border-t bg-row-hover">
<td class="px-3 py-2" colspan={COLUMNS}>
<MoneyEditor
mode={editing.mode}
onclose={() => (editing = null)}
piggy={item}
rooms={room}
/>
</td>
</tr>
{/if}
{/each}
</tbody>
{/each}
</table>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-52">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem
onSelect={() => goto(`/piggy-banks/show/${target.id}`)}
>Show</ContextMenuItem
>
<ContextMenuItem
onSelect={() => goto(`/piggy-banks/edit/${target.id}`)}
>Edit</ContextMenuItem
>
<ContextMenuItem
disabled={!canAdd(target)}
onSelect={() => startMoney(target, "add")}
>Add money</ContextMenuItem
>
<ContextMenuItem
disabled={!canRemove(target)}
onSelect={() => startMoney(target, "remove")}
>Remove money</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
location.assign(
oldUiUrl(`${location.origin}/piggy-banks/show/${target.id}`)
)}
>Open in old UI</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => {
if (!armed) {
e.preventDefault();
armed = true;
return;
}
remove(target);
}}
variant="destructive"
>{armed ? "Press again to delete" : "Delete"}</ContextMenuItem
>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/piggy-banks/create"
><Plus />Create new piggy bank</a
>
{/snippet}
</Box>
{#if room.size > 0}
<Box body="p-0" class="mt-3" title="Account status">
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Account</th>
<th class="{head} {wide} text-right">Balance</th>
<th class="{head} text-right">Left for piggy banks</th>
<th class="{head} {wide} text-right">Sum of piggy banks</th>
<th class="{head} {wide} text-right">Saved so far</th>
<th class="{head} {wide} text-right">Left to save</th>
</tr>
</thead>
<tbody>
{#each [...room] as [id, r] (id)}
{@const linked = all.filter((p) =>
p.attributes.accounts?.some((a) => a.account_id === id)
)}
{@const a = r.account?.attributes}
{@const money = {
code: a?.currency_code,
decimals: a?.currency_decimal_places,
symbol: a?.currency_symbol,
}}
{@const targets = linked.reduce(
(t, p) => t + toNumber(p.attributes.target_amount),
0
)}
<tr class="h-7 border-cladd-outline/60 border-t hover:bg-row-hover">
<td class={cell}>
<a class={LINK} href="/accounts/show/{id}">{a?.name ?? id}</a>
</td>
<td class="{cell} {wide} text-right">
<Amount {...money} amount={r.balance} tone="auto" />
</td>
<td class="{cell} text-right">
<Amount {...money} amount={r.free} tone="auto" />
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({ ...money, amount: targets })}</span
>
</td>
<td class="{cell} {wide} text-right">
<Amount {...money} amount={r.earmarked} tone="income" />
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({
...money,
amount: Math.max(0, targets - r.earmarked),
})}</span
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Box>
{/if}