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

+501
View File
@@ -0,0 +1,501 @@
<script lang="ts">
import Ban from "@lucide/svelte/icons/ban";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { BasicSummary, Bill, Item } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
byObjectGroup,
LINK,
sumByCurrency,
swapOrder,
type Total,
} 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 PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
average,
billKey,
dueIn,
lastMonths,
money,
monthly,
nextExpected,
repeats,
status,
strip,
yearly,
} from "#lib/components/subscriptions/bills.js";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import Strip from "#lib/components/subscriptions/Strip.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const months = $derived(lastMonths(period.end));
const billsKey = $derived(["subscriptions", period.start, period.end]);
const bills = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<Bill>("/v1/bills", { end: period.end, start: period.start }),
queryKey: billsKey,
}));
const history = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<Bill>("/v1/bills", { end: period.end, start: `${months[0]}-01` }),
queryKey: ["subscriptions", "history", months[0], period.end],
}));
const summary = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
api<BasicSummary>("/v1/summary/basic", {
query: { end: period.end, start: period.start },
}),
queryKey: ["subscriptions", "summary", period.start, period.end],
}));
const groups = createQuery(() => lists.objectGroups());
const hideInactive = $derived(
page.url.searchParams.get("inactive") === "hide"
);
const visible = $derived(
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
);
const sections = $derived(byObjectGroup(visible));
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 pastCells = $derived(
new Map((history.data ?? []).map((b) => [b.id, b.attributes]))
);
const activeOnly = (items: Item<Bill>[]) =>
items.filter((b) => b.attributes.active).map((b) => b.attributes);
const perMonth = (items: Item<Bill>[]) =>
sumByCurrency(activeOnly(items).map(monthly));
const perYear = (items: Item<Bill>[]) =>
sumByCurrency(activeOnly(items).map(yearly));
function pick(prefix: string): Total[] {
return Object.values(summary.data ?? {})
.filter((e) => e.key?.startsWith(prefix) && e.monetary_value)
.map((e) => ({
amount: Math.abs(e.monetary_value ?? 0),
code: e.currency_code ?? "",
decimals: e.currency_decimal_places ?? 2,
symbol: e.currency_symbol ?? "",
}));
}
const paid = $derived(pick("bills-paid-in-"));
const unpaid = $derived(pick("bills-unpaid-in-"));
const expected = $derived(
sumByCurrency(
activeOnly(bills.data ?? []).map((b) =>
money(
b,
average(b) *
((b.paid_dates?.length ?? 0) + dueIn(period.range, b).length)
)
)
).filter((t) => t.amount > 0)
);
let menuFor = $state<Item<Bill> | null>(null);
let armed = $state(false);
let failure = $state("");
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
const edit = (b: Item<Bill>) => goto(`/subscriptions/edit/${b.id}`);
function toggleInactive() {
const url = new URL(page.url.href);
if (hideInactive) {
url.searchParams.delete("inactive");
} else {
url.searchParams.set("inactive", "hide");
}
goto(url, { replace: true });
}
async function update(b: Item<Bill>, body: Record<string, unknown>) {
failure = "";
try {
await api(`/v1/bills/${b.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(billsKey, current, other);
nav.move(nav.cursor + dir);
update(current, { order });
}
async function remove(b: Item<Bill>) {
failure = "";
try {
await api(`/v1/bills/${b.id}`, { method: "DELETE" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
edit,
extra: () => [
{
group: "Subscriptions",
keys: "alt+j",
label: "Move down in group",
run: () => move(1),
},
{
group: "Subscriptions",
keys: "alt+k",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+˚",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Subscriptions",
keys: "c",
label: "New subscription",
run: () => goto("/subscriptions/create"),
},
{
group: "Subscriptions",
keys: "i",
label: hideInactive ? "Show inactive" : "Hide inactive",
run: toggleInactive,
},
],
id: "bills",
items: () => rows,
key: billKey,
open: show,
});
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";
</script>
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
{#snippet actions()}
<a class={buttonVariants({ size: "sm" })} href="/subscriptions/create">
<Plus />Create new subscription<span class="kbd max-sm:hidden">c</span>
</a>
{/snippet}
</PageHeader>
<div class="mb-3 grid gap-3 sm:grid-cols-3">
{#each [
{ label: "Expected this period", totals: expected },
{ label: "Paid", totals: paid },
{ label: "Still to pay", totals: unpaid },
] as stat (stat.label)}
<Box body="px-3 py-2" title={stat.label}>
<div class="text-[15px]">
<Totals
empty={summary.isPending ? "" : "Nothing"}
tone={stat.label === "Paid" ? "income" : "expense"}
totals={stat.totals}
/>
</div>
</Box>
{/each}
</div>
<Box body="p-0" title="Subscriptions">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
<button
class={buttonVariants({ size: "xs", variant: "outline" })}
onclick={toggleInactive}
type="button"
>
{hideInactive ? "Show inactive" : "Hide inactive"}
<span class="kbd max-sm:hidden">i</span>
</button>
{/snippet}
{#if bills.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading subscriptions</p>
{:else if bills.error}
<p class="px-3 py-6 text-expense">{bills.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-6 text-cladd-fg-soft">
No subscriptions yet.
<a class={LINK} href="/subscriptions/create">Create a subscription</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}>Name</th>
<th class="{head} text-right">Amount</th>
<th class={head}>Paid this period</th>
<th class="{head} hidden md:table-cell">Next expected match</th>
<th class="{head} hidden md:table-cell">Repeats</th>
<th class="{head} hidden xl:table-cell">
<span class="inline-flex gap-[3px]">
{#each months as month (month)}
<span class="w-2.5 text-center text-[10px]"
>{formatDate(`${month}-01`, "month").slice(0, 1)}</span
>
{/each}
</span>
</th>
<th class="{head} hidden text-right lg:table-cell">Per year</th>
</tr>
</thead>
{#each sections as section, s (section.id)}
{@const monthTotals = perMonth(section.items)}
<tbody>
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60"
>
<td class="{cell} font-semibold">
{section.title}
<span class="ms-1 font-normal text-cladd-fg-softer"
>{section.items.length}</span
>
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={monthTotals} />
</td>
<td class="{cell} text-[12px] text-cladd-fg-soft">
{monthTotals.length > 0 ? "a month" : ""}
</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
>
<Totals tone="expense" totals={perYear(section.items)} />
</td>
</tr>
{#each section.items as item, j (item.id)}
{@const i = offsets[s] + j}
{@const bill = item.attributes}
{@const ends = bill.end_date?.slice(0, 10)}
<tr
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"} {bill.active
? ""
: "text-cladd-fg-softer"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = item;
nav.cursor = i;
}}
>
<td class={cell}>
<a
class={bill.active ? LINK : "hover:underline"}
href="/subscriptions/show/{item.id}"
>
{#if !bill.active}
<Ban
aria-label="Inactive"
class="me-1 inline size-3.5 align-[-2px]"
/>
{/if}
{bill.name}
</a>
</td>
<td class="{cell} whitespace-nowrap text-right">
<Amount
amount={bill.amount_min}
code={bill.currency_code}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone={bill.active ? "expense" : "neutral"}
/>
{#if bill.amount_max !== bill.amount_min}
<span class="text-cladd-fg-softer">–</span>
<Amount
amount={bill.amount_max}
decimals={bill.currency_decimal_places}
tone={bill.active ? "expense" : "neutral"}
/>
{/if}
</td>
<td class={cell}>
<PaidStatus state={status(bill, period.range)} />
</td>
<td class="{cell} num hidden md:table-cell">
{bill.active ? formatDate(nextExpected(bill)) : ""}
</td>
<td class="{cell} hidden whitespace-nowrap md:table-cell">
{repeats(bill)}
{#if ends}
<span
class={ends < today()
? "text-expense"
: "text-cladd-fg-soft"}
>, ends {formatDate(ends)}</span
>
{/if}
</td>
<td class="{cell} hidden xl:table-cell">
<Strip
cells={strip(pastCells.get(item.id) ?? bill, months)}
{months}
/>
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if bill.active}
<Amount {...yearly(bill)} tone="expense" />
{/if}
</td>
</tr>
{/each}
</tbody>
{/each}
{#if sections.length > 1}
<tfoot>
<tr class="border-cladd-outline border-t-2">
<td class="{cell} font-semibold">
Total, active subscriptions
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={perMonth(rows)} />
</td>
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
>
<Totals tone="expense" totals={perYear(rows)} />
</td>
</tr>
</tfoot>
{/if}
</table>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
<ContextMenuSubContent class="max-h-80 w-52">
{#each groups.data ?? [] as group (group.id)}
<ContextMenuItem
disabled={group.id === target.attributes.object_group_id}
onSelect={() =>
update(target, {
object_group_title: group.attributes.title,
})}
>{group.attributes.title}</ContextMenuItem
>
{/each}
<ContextMenuSeparator />
<ContextMenuItem
disabled={!target.attributes.object_group_id}
onSelect={() => update(target, { object_group_title: "" })}
>No group</ContextMenuItem
>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
location.assign(`/rules/create-from-bill/${target.id}`)}
>Create rule from subscription</ContextMenuItem
>
<ContextMenuItem
onSelect={() =>
location.assign(
oldUiUrl(`${location.origin}/subscriptions/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="/subscriptions/create"
><Plus />Create new subscription</a
>
{/snippet}
</Box>