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

+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
export function load({ params, url }) {
const rest = params.rest ? `/${params.rest}` : "";
redirect(307, `/subscriptions${rest}${url.search}`);
}
+309
View File
@@ -0,0 +1,309 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, ObjectGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { LINK, swapOrder } from "#lib/components/groups/object-groups.js";
import { errorText } from "#lib/components/misc/action.svelte.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
const groupQuery = lists.objectGroups();
const groups = createQuery(() => groupQuery);
const bills = createQuery(() => lists.bills());
const piggies = createQuery(() => lists.piggyBanks());
const accounts = createQuery(() => lists.accounts("all"));
const rows = $derived(
[...(groups.data ?? [])].sort(
(a, b) => a.attributes.order - b.attributes.order
)
);
interface Member {
href: string;
id: string;
name: string;
}
const members = $derived.by(() => {
const map = new Map<string, Record<string, Member[]>>();
const add = (
group: string | null | undefined,
kind: string,
member: Member
) => {
if (!group) {
return;
}
const entry = map.get(group) ?? {};
entry[kind] = [...(entry[kind] ?? []), member];
map.set(group, entry);
};
for (const b of bills.data ?? []) {
add(b.attributes.object_group_id, "Subscriptions", {
href: `/subscriptions/show/${b.id}`,
id: b.id,
name: b.attributes.name ?? b.id,
});
}
for (const p of piggies.data ?? []) {
add(p.attributes.object_group_id, "Piggy banks", {
href: `/piggy-banks/show/${p.id}`,
id: p.id,
name: p.attributes.name,
});
}
for (const a of accounts.data ?? []) {
add(a.attributes.object_group_id, "Accounts", {
href: `/accounts/show/${a.id}`,
id: a.id,
name: a.attributes.name,
});
}
return map;
});
let renaming = $state<string | null>(null);
let title = $state("");
let menuFor = $state<Item<ObjectGroup> | null>(null);
let armed = $state(false);
let failure = $state("");
async function run(task: () => Promise<unknown>) {
failure = "";
try {
await task();
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
function rename(group: Item<ObjectGroup> | undefined): void {
if (group) {
nav.cursor = rows.indexOf(group);
({ title } = group.attributes);
renaming = group.id;
}
}
async function saveTitle(group: Item<ObjectGroup>) {
const next = title.trim();
renaming = null;
if (next && next !== group.attributes.title) {
await run(() =>
api(`/v1/object-groups/${group.id}`, {
body: { title: next },
method: "PUT",
})
);
}
}
function move(dir: 1 | -1): void {
const { current } = nav;
const other = rows[nav.cursor + dir];
if (!(current && other)) {
return;
}
const order = swapOrder(groupQuery.queryKey, current, other);
nav.move(nav.cursor + dir);
run(() =>
api(`/v1/object-groups/${current.id}`, {
body: { order },
method: "PUT",
})
);
}
const nav: ListNav<Item<ObjectGroup>> = new ListNav<Item<ObjectGroup>>({
edit: (g) => goto(`/groups/edit/${g.id}`),
extra: () => [
{
group: "Groups",
keys: "r",
label: "Rename group",
run: () => rename(nav.current),
},
{
group: "Groups",
keys: "alt+j",
label: "Move group down",
run: () => move(1),
},
{
group: "Groups",
keys: "alt+k",
label: "Move group up",
run: () => move(-1),
},
{
group: "Groups",
hidden: true,
keys: "alt+∆",
label: "Move group down",
run: () => move(1),
},
{
group: "Groups",
hidden: true,
keys: "alt+˚",
label: "Move group up",
run: () => move(-1),
},
],
id: "groups",
items: () => rows,
key: (g) => g.id,
open: (g) => goto(`/groups/edit/${g.id}`),
});
nav.use();
const head =
"px-3 py-1.5 text-left font-semibold text-[12px] text-cladd-fg-soft";
</script>
<PageHeader crumbs={[{ label: "Groups" }]} subtitle="Overview" title="Groups" />
<Box body="p-0" title="Groups">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
{/snippet}
{#if groups.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading groups</p>
{:else if rows.length === 0}
<p class="max-w-prose px-3 py-6 text-cladd-fg-soft">
Some things in Firefly III can be divided into groups. Piggy banks and
subscriptions have a "Group" field on their edit page. Give them a group
and it shows up here.
</p>
{:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger>
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b">
<th class="{head} w-10 text-right">#</th>
<th class="{head} w-1/4">Title</th>
<th class={head}>Members</th>
</tr>
</thead>
<tbody>
{#each rows as group, i (group.id)}
{@const kinds = members.get(group.id) ?? {}}
<tr
class="border-cladd-outline/60 border-t align-top {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = group;
nav.cursor = i;
}}
>
<td class="num px-3 py-1.5 text-right text-cladd-fg-softer">
{i + 1}
</td>
<td class="px-3 py-1">
{#if renaming === group.id}
<form
onsubmit={(e) => {
e.preventDefault();
saveTitle(group);
}}
>
<Input
aria-label="Group title"
class="h-7"
onblur={() => (renaming = null)}
onkeydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
renaming = null;
}
}}
bind:value={title}
{@attach (node) => node.focus()}
/>
</form>
{:else}
<button
class="py-0.5 text-left font-semibold"
ondblclick={() => rename(group)}
title="Double-click or press r to rename"
type="button"
>
{group.attributes.title}
</button>
{/if}
</td>
<td class="px-3 py-1.5">
{#each Object.entries(kinds) as [kind, list] (kind)}
<div>
<span class="text-cladd-fg-soft">{kind}:</span>
{#each list as member, m (member.id)}
{#if m > 0}
,
{/if}
<a class={LINK} href={member.href}>{member.name}</a>
{/each}
</div>
{:else}
<span class="text-cladd-fg-softer">Empty</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem onSelect={() => rename(target)}
>Rename</ContextMenuItem
>
<ContextMenuItem onSelect={() => goto(`/groups/edit/${target.id}`)}
>Edit</ContextMenuItem
>
<ContextMenuItem
onSelect={() =>
location.assign(oldUiUrl(`${location.origin}/groups`))}
>Open in old UI</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => {
if (!armed) {
e.preventDefault();
armed = true;
return;
}
run(() =>
api(`/v1/object-groups/${target.id}`, { method: "DELETE" })
);
}}
variant="destructive"
>{armed ? "Press again to delete" : "Delete"}</ContextMenuItem
>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
</Box>
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/groups/edit/${params.id}`);
}
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type {
Bill,
ObjectGroup,
One,
Page,
PiggyBank,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { LINK } from "#lib/components/groups/object-groups.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const group = createQuery(() => ({
queryFn: () => api<One<ObjectGroup>>(`/v1/object-groups/${id}`),
queryKey: ["object-group", id],
}));
const bills = createQuery(() => ({
queryFn: () =>
api<Page<Bill>>(`/v1/object-groups/${id}/bills`, {
query: { limit: 1000 },
}),
queryKey: ["object-group", id, "bills"],
}));
const piggies = createQuery(() => ({
queryFn: () =>
api<Page<PiggyBank>>(`/v1/object-groups/${id}/piggy-banks`, {
query: { limit: 1000 },
}),
queryKey: ["object-group", id, "piggy-banks"],
}));
const current = $derived(group.data?.data.attributes.title ?? "");
let title = $state<string | null>(null);
const value = $derived(title ?? current);
const action = new Action();
async function save() {
const next = value.trim();
const ok = await action.run(() =>
api(`/v1/object-groups/${id}`, { body: { title: next }, method: "PUT" })
);
if (ok) {
await invalidate();
await goto("/groups");
}
}
async function remove() {
const ok = await action.run(() =>
api(`/v1/object-groups/${id}`, { method: "DELETE" })
);
if (ok) {
await goto("/groups");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: "Update group",
run: save,
},
]);
</script>
<PageHeader
crumbs={[{ href: "/groups", label: "Groups" }, { label: current || id }]}
title={current ? `Edit group "${current}"` : "Edit group"}
/>
{#if group.error}
<p class="text-expense">{group.error.message}</p>
{:else if !group.data}
<p class="text-cladd-fg-soft">Loading group</p>
{:else}
<div class="grid items-start gap-3 lg:grid-cols-2">
<form
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="group-title" label="Title" problem={action.field("title")}>
<Input
id="group-title"
oninput={(e) => (title = e.currentTarget.value)}
required
{value}
/>
</Field>
{#snippet footer()}
<Button disabled={action.busy} size="sm" type="submit">
Update group <span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
<ConfirmButton class="ms-auto" onconfirm={remove}
>Delete</ConfirmButton
>
{/snippet}
</Box>
</form>
<Box body="p-3" title="Members">
{@const billList = bills.data?.data ?? []}
{@const piggyList = piggies.data?.data ?? []}
{#if billList.length > 0}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
Subscriptions
</h3>
<ul class="mb-3 flex flex-col gap-0.5">
{#each billList as bill (bill.id)}
<li>
<a class={LINK} href="/subscriptions/show/{bill.id}"
>{bill.attributes.name}</a
>
</li>
{/each}
</ul>
{/if}
{#if piggyList.length > 0}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
Piggy banks
</h3>
<ul class="flex flex-col gap-0.5">
{#each piggyList as piggy (piggy.id)}
<li>
<a class={LINK} href="/piggy-banks/show/{piggy.id}"
>{piggy.attributes.name}</a
>
</li>
{/each}
</ul>
{/if}
{#if billList.length === 0 && piggyList.length === 0}
<p class="text-cladd-fg-soft">
{bills.isPending ? "Loading members" : "Nothing is in this group."}
</p>
{/if}
</Box>
</div>
{/if}
+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}
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/show/${params.id}?money=add`);
}
@@ -0,0 +1,11 @@
<script lang="ts">
import PiggyForm from "#lib/components/piggy-banks/PiggyForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
</script>
<PageHeader
crumbs={[{ href: "/piggy-banks", label: "Piggy banks" }, { label: "Create" }]}
title="Create a new piggy bank"
/>
<PiggyForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/edit/${params.id}`);
}
@@ -0,0 +1,34 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { One, PiggyBank } from "#lib/api/types.js";
import PiggyForm from "#lib/components/piggy-banks/PiggyForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const piggy = createQuery(() => ({
queryFn: () => api<One<PiggyBank>>(`/v1/piggy-banks/${id}`),
queryKey: ["piggy-bank", id],
}));
const name = $derived(piggy.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/piggy-banks", label: "Piggy banks" },
{ href: `/piggy-banks/show/${id}`, label: name || id },
{ label: "Edit" },
]}
title={name ? `Edit piggy bank "${name}"` : "Edit piggy bank"}
/>
{#if piggy.data}
{#key id}
<PiggyForm piggy={piggy.data.data} />
{/key}
{:else if piggy.error}
<p class="text-expense">{piggy.error.message}</p>
{:else}
<p class="text-cladd-fg-soft">Loading piggy bank</p>
{/if}
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/show/${params.id}?money=remove`);
}
@@ -0,0 +1,400 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type {
Item,
One,
PiggyBank,
PiggyBankEvent,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { tones } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { LINK } from "#lib/components/groups/object-groups.js";
import MoneyEditor from "#lib/components/piggy-banks/MoneyEditor.svelte";
import {
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 { addDays, today } from "#lib/dates.js";
import {
formatDate,
formatDateTime,
formatMoney,
percent,
toNumber,
} from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const MIN_DAYS = 30;
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<PiggyBank>>(`/v1/piggy-banks/${id}`),
queryKey: ["piggy-bank", id],
}));
const events = createQuery(() => ({
queryFn: () => all<PiggyBankEvent>(`/v1/piggy-banks/${id}/events`),
queryKey: ["piggy-bank", id, "events"],
}));
const piggies = createQuery(() => lists.piggyBanks());
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const item = $derived(query.data?.data);
const piggy = $derived(item?.attributes);
const room = $derived(
rooms(piggies.data ?? [], [
...(assets.data ?? []),
...(liabilities.data ?? []),
])
);
const mode = $derived(page.url.searchParams.get("money") as Mode | null);
const groupIds = $derived([
...new Set(
(events.data ?? [])
.map((e) => e.attributes.transaction_group_id ?? "")
.filter(Boolean)
),
]);
const linkedDates = createQuery(() => ({
enabled: groupIds.length > 0,
queryFn: async () =>
Object.fromEntries(
await Promise.all(
groupIds.map(async (group) => {
const one = await api<One<TransactionGroup>>(
`/v1/transactions/${group}`
);
return [group, one.data.attributes.transactions[0]?.date ?? ""];
})
)
),
queryKey: ["piggy-bank", id, "event-dates", groupIds],
}));
const when = (e: Item<PiggyBankEvent>) =>
linkedDates.data?.[e.attributes.transaction_group_id ?? ""] ||
e.attributes.created_at ||
"";
const history = $derived(
[...(events.data ?? [])].sort((a, b) => when(b).localeCompare(when(a)))
);
const points = $derived.by(() => {
const byDay = new Map<string, number>();
let running = 0;
for (const entry of [...history].reverse()) {
const day = when(entry).slice(0, 10);
const before = addDays(day, -1);
if (!byDay.has(before)) {
byDay.set(before, running);
}
running += toNumber(entry.attributes.amount);
byDay.set(day, running);
}
const [first = today()] = byDay.keys();
const start = piggy?.start_date?.slice(0, 10) ?? first;
const [from] = [start, first, addDays(today(), -MIN_DAYS)].sort();
byDay.set(from, 0);
byDay.set(today(), running);
return [...byDay]
.map(([x, y]) => ({ x, y }))
.sort((a, b) => a.x.localeCompare(b.x));
});
function setMode(next: Mode | null) {
const url = new URL(page.url.href);
if (next) {
url.searchParams.set("money", next);
} else {
url.searchParams.delete("money");
}
goto(url, { replace: true });
}
async function remove() {
await api(`/v1/piggy-banks/${id}`, { method: "DELETE" });
await goto("/piggy-banks");
await invalidate();
}
useKeys(() => [
{
group: "Piggy bank",
keys: "e",
label: "Edit piggy bank",
run: () => goto(`/piggy-banks/edit/${id}`),
},
{
group: "Piggy bank",
keys: "+",
label: "Add money",
run: () => setMode("add"),
},
{
group: "Piggy bank",
hidden: true,
keys: "=",
label: "Add money",
run: () => setMode("add"),
},
{
group: "Piggy bank",
keys: "-",
label: "Remove money",
run: () => setMode("remove"),
},
]);
const label = "px-3 py-1.5 align-top text-cladd-fg-soft";
const value = "px-3 py-1.5 text-right";
const row = "border-cladd-outline/60 border-b";
</script>
<PageHeader
crumbs={[
{ href: "/piggy-banks", label: "Piggy banks" },
{ label: piggy?.name ?? id },
]}
title={piggy?.name ?? "Piggy bank"}
>
{#snippet actions()}
<a
class={buttonVariants({ size: "sm", variant: "ghost" })}
data-sveltekit-reload
href={oldUiUrl(`${page.url.origin}/piggy-banks/show/${id}`)}
>Open in old UI</a
>
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/piggy-banks/edit/{id}"
>Edit <span class="kbd">e</span></a
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</PageHeader>
{#if query.error}
<p class="text-expense">{query.error.message}</p>
{:else if !(piggy && item)}
<p class="text-cladd-fg-soft">Loading piggy bank</p>
{:else}
{@const target = toNumber(piggy.target_amount)}
<div class="grid items-start gap-3 lg:grid-cols-3">
<div class="flex flex-col gap-3 lg:col-span-2">
<Box title="Events">
{#if events.isPending}
<p class="text-cladd-fg-soft">Loading events</p>
{:else}
<LineChart
area
axisFormat={(v) => formatMoney(piggyMoney(piggy, v))}
height={260}
label="Saved over time"
series={[
{
color: tones.income,
format: (v) => formatMoney(piggyMoney(piggy, v)),
key: "saved",
label: "Saved so far",
points,
},
]}
toggle={false}
zero
/>
{/if}
</Box>
{#if piggy.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap">{piggy.notes}</p>
</Box>
{/if}
</div>
<div class="flex flex-col gap-3">
<Box body="p-0" title="Details">
<table class="w-full text-[13px]">
<tbody>
<tr class={row}>
<td class={label}>Save on account(s)</td>
<td class={value}>
{#each piggy.accounts ?? [] as account, i (account.account_id)}
{#if i > 0}
,
{/if}
<a class={LINK} href="/accounts/show/{account.account_id}"
>{account.name}</a
>
{/each}
</td>
</tr>
<tr class={row}>
<td class={label}>Group</td>
<td class={value}>
{#if piggy.object_group_id}
<a class={LINK} href="/groups/edit/{piggy.object_group_id}"
>{piggy.object_group_title}</a
>
{:else}
<span class="text-cladd-fg-softer">No group</span>
{/if}
</td>
</tr>
<tr class={row}>
<td class={label}>Target amount</td>
<td class="{value} num">
{target > 0
? formatMoney(piggyMoney(piggy, piggy.target_amount))
: "No target"}
</td>
</tr>
{#if (piggy.accounts ?? []).length > 1}
{#each piggy.accounts ?? [] as account (account.account_id)}
<tr class={row}>
<td class={label}>Saved so far ({account.name})</td>
<td class={value}>
<Amount
{...piggyMoney(piggy, account.current_amount)}
tone="income"
/>
</td>
</tr>
{/each}
{/if}
<tr class={row}>
<td class={label}>Saved so far</td>
<td class={value}>
<Amount
{...piggyMoney(piggy, piggy.current_amount)}
tone="income"
/>
</td>
</tr>
{#if target > 0}
<tr class={row}>
<td class={label}>Left to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.left_to_save))}
</td>
</tr>
{/if}
<tr class={row}>
<td class={label}>Start date</td>
<td class="{value} num">
{formatDate(piggy.start_date) || "No start date"}
</td>
</tr>
<tr class={row}>
<td class={label}>Target date</td>
<td class="{value} num">
{formatDate(piggy.target_date) || "No target date"}
</td>
</tr>
{#if piggy.target_date && toNumber(piggy.save_per_month) > 0}
<tr class={row}>
<td class={label}>Suggested monthly amount to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.save_per_month))}
</td>
</tr>
{/if}
</tbody>
</table>
{#if target > 0 &&
piggy.percentage !== null &&
piggy.percentage !== undefined}
<div class="flex items-center gap-2 px-3 py-2.5">
<Meter
max={target}
segments={[
{ color: tones.income, value: toNumber(piggy.current_amount) },
]}
/>
<span class="num text-[12px] text-cladd-fg-soft"
>{percent(piggy.percentage / 100)}</span
>
</div>
{/if}
{#snippet footer()}
{#if mode}
<MoneyEditor
{mode}
onclose={() => setMode(null)}
piggy={item}
rooms={room}
/>
{:else}
<button
class={buttonVariants({ size: "sm", variant: "outline" })}
onclick={() => setMode("add")}
type="button"
>
Add money <span class="kbd">+</span>
</button>
<button
class={buttonVariants({ size: "sm", variant: "outline" })}
disabled={toNumber(piggy.current_amount) <= 0}
onclick={() => setMode("remove")}
type="button"
>
Remove money <span class="kbd">-</span>
</button>
{/if}
{/snippet}
</Box>
<Box body="p-0" title="Event history">
{#if history.length > 0}
<table class="w-full text-[13px]">
<tbody>
{#each history as entry (entry.id)}
{@const amount = toNumber(entry.attributes.amount)}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0"
>
<td class="px-3 py-1">
{#if entry.attributes.transaction_group_id}
<a
class={LINK}
href="/transactions/show/{entry.attributes
.transaction_group_id}"
>{formatDate(when(entry))}</a
>
{:else}
{formatDateTime(entry.attributes.created_at)}
{/if}
</td>
<td
class="px-3 py-1 text-right {amount < 0
? "text-expense"
: "text-income"}"
>
{amount < 0 ? "Removed" : "Added"}
<span class="num"
>{formatMoney(piggyMoney(piggy, Math.abs(amount)))}</span
>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="px-3 py-3 text-cladd-fg-soft">
{events.isPending ? "Loading events" : "No events yet."}
</p>
{/if}
</Box>
</div>
</div>
{/if}
+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>
@@ -0,0 +1,14 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import BillForm from "#lib/components/subscriptions/BillForm.svelte";
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ label: "Create" },
]}
title="Create a new subscription"
/>
<BillForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/subscriptions/edit/${params.id}`);
}
@@ -0,0 +1,34 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Bill, One } from "#lib/api/types.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import BillForm from "#lib/components/subscriptions/BillForm.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const bill = createQuery(() => ({
queryFn: () => api<One<Bill>>(`/v1/bills/${id}`),
queryKey: ["bill", id],
}));
const name = $derived(bill.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ href: `/subscriptions/show/${id}`, label: name || id },
{ label: "Edit" },
]}
title={name ? `Edit subscription "${name}"` : "Edit subscription"}
/>
{#if bill.data}
{#key id}
<BillForm bill={bill.data.data} />
{/key}
{:else if bill.error}
<p class="text-expense">{bill.error.message}</p>
{:else}
<p class="text-cladd-fg-soft">Loading subscription</p>
{/if}
@@ -0,0 +1,353 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Bill,
One,
Page,
Rule,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Columns from "#lib/components/charts/Columns.svelte";
import { seriesColor, tones } from "#lib/components/charts/chart.js";
import {
LINK,
sumByCurrency,
type Total,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
nextExpected,
repeats,
status,
} from "#lib/components/subscriptions/bills.js";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import { addMonths } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.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 LEADING_DAY = /^\d+\s*/;
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
api<One<Bill>>(`/v1/bills/${id}`, {
query: { end: period.end, start: period.start },
}),
queryKey: ["bill", id, period.start, period.end],
}));
const rules = createQuery(() => ({
queryFn: () => api<Page<Rule>>(`/v1/bills/${id}/rules`),
queryKey: ["bill", id, "rules"],
}));
const transactions = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/bills/${id}/transactions`),
queryKey: ["bill", id, "transactions"],
}));
const bill = $derived(query.data?.data.attributes);
const payments = $derived(
(transactions.data ?? []).flatMap((g) =>
g.attributes.transactions
.filter((t) => t.bill_id === id)
.map((t) => {
const foreign =
t.foreign_currency_code === bill?.currency_code &&
toNumber(t.foreign_amount) !== 0;
return {
amount: Math.abs(toNumber(foreign ? t.foreign_amount : t.amount)),
code: (foreign ? t.foreign_currency_code : t.currency_code) ?? "",
date: t.date.slice(0, 10),
decimals:
(foreign
? t.foreign_currency_decimal_places
: t.currency_decimal_places) ?? 2,
symbol:
(foreign ? t.foreign_currency_symbol : t.currency_symbol) ?? "",
};
})
)
);
type Payment = (typeof payments)[number];
function averages(list: Payment[]): Total[] {
return sumByCurrency(list).map((t) => ({
...t,
amount: t.amount / list.filter((p) => p.code === t.code).length,
}));
}
const year = $derived(period.start.slice(0, 4));
const yearAverage = $derived(
averages(payments.filter((p) => p.date.startsWith(year)))
);
const overallAverage = $derived(averages(payments));
const codes = $derived([...new Set(payments.map((p) => p.code))]);
const chartRows = $derived.by(() => {
if (payments.length === 0) {
return [];
}
const dates = payments.map((p) => p.date.slice(0, 7)).sort();
const months: string[] = [];
for (
let m = `${dates[0]}-01`;
m.slice(0, 7) <= (dates.at(-1) ?? "");
m = addMonths(m, 1)
) {
months.push(m.slice(0, 7));
}
const min = toNumber(bill?.amount_min);
const max = toNumber(bill?.amount_max);
return months.map((month) => {
const values = Object.fromEntries(
codes.map((code) => [
code,
payments
.filter((p) => p.code === code && p.date.startsWith(month))
.reduce((sum, p) => sum + p.amount, 0),
])
);
const own = values[bill?.currency_code ?? ""] ?? 0;
return {
color: own > 0 && (own < min || own > max) ? tones.warn : undefined,
key: month,
label: formatDate(`${month}-01`, "short").replace(LEADING_DAY, ""),
note: month.slice(0, 4),
values,
};
});
});
const chartKeys = $derived(
codes.map((code, i) => ({
color: seriesColor(i),
key: code,
label: `Paid in ${code}`,
}))
);
async function remove() {
await api(`/v1/bills/${id}`, { method: "DELETE" });
await goto("/subscriptions");
await invalidate();
}
useKeys(() => [
{
group: "Subscription",
keys: "E",
label: "Edit subscription",
run: () => goto(`/subscriptions/edit/${id}`),
},
]);
const label = "w-1/2 px-3 py-1.5 align-top text-cladd-fg-soft";
const value = "px-3 py-1.5";
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ label: bill?.name ?? id },
]}
title={bill?.name ?? "Subscription"}
>
{#snippet actions()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/subscriptions/edit/{id}"
>Edit <span class="kbd">E</span></a
>
{/snippet}
</PageHeader>
{#if query.error}
<p class="text-expense">{query.error.message}</p>
{:else if !bill}
<p class="text-cladd-fg-soft">Loading subscription</p>
{:else}
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="p-0" title={bill.name ?? ""}>
<table class="w-full text-[13px]">
<tbody>
<tr class="border-cladd-outline/60 border-b">
<td class="{value} leading-6" colspan="2">
Matches transactions between
<Amount
amount={bill.amount_min}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone="expense"
/>
and
<Amount
amount={bill.amount_max}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone="expense"
/>. Repeats {repeats(bill)}.
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Subscription is active</td>
<td class={value}>{bill.active ? "Yes" : "No"}</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Paid this period ({period.label})</td>
<td class={value}>
<PaidStatus state={status(bill, period.range)} />
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Next expected match</td>
<td class="{value} num">
{formatDate(nextExpected(bill)) || "unknown"}
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Group</td>
<td class={value}>
{#if bill.object_group_id}
<a class={LINK} href="/groups/edit/{bill.object_group_id}"
>{bill.object_group_title}</a
>
{:else}
<span class="text-cladd-fg-softer">No group</span>
{/if}
</td>
</tr>
{#if bill.end_date}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>End date</td>
<td class="{value} num">{formatDate(bill.end_date)}</td>
</tr>
{/if}
{#if bill.extension_date}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Extension date</td>
<td class="{value} num">{formatDate(bill.extension_date)}</td>
</tr>
{/if}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Average subscription amount ({year})</td>
<td class="{value} text-right">
<Totals
empty={transactions.isPending ? "" : "No transactions"}
tone="expense"
totals={yearAverage}
/>
</td>
</tr>
<tr>
<td class={label}>Average subscription amount (overall)</td>
<td class="{value} text-right">
<Totals
empty={transactions.isPending ? "" : "No transactions"}
tone="expense"
totals={overallAverage}
/>
</td>
</tr>
</tbody>
</table>
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/subscriptions/edit/{id}"
>Edit</a
>
<a
class={buttonVariants({ size: "sm", variant: "ghost" })}
data-sveltekit-reload
href={oldUiUrl(`${page.url.origin}/subscriptions/show/${id}`)}
>Open in old UI</a
>
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</Box>
<div class="flex flex-col gap-3">
<Box body="p-3" title="Rules related to this subscription">
{#if rules.data?.data.length}
<ul class="flex flex-col gap-1">
{#each rules.data.data as rule (rule.id)}
<li>
<a
class={LINK}
data-sveltekit-reload
href="/rules/edit/{rule.id}"
>{rule.attributes.title}</a
>
{#if !rule.attributes.active}
<span class="text-cladd-fg-softer">(inactive rule)</span>
{/if}
</li>
{/each}
</ul>
{:else if rules.isPending}
<p class="text-cladd-fg-soft">Loading rules</p>
{:else}
<p class="text-cladd-fg-soft">
No rule links transactions to this subscription.
</p>
{/if}
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
data-sveltekit-reload
href="/rules/create-from-bill/{id}"
>Create rule from subscription</a
>
{/snippet}
</Box>
{#if bill.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap">{bill.notes}</p>
</Box>
{/if}
</div>
</div>
<Box class="mt-3" title="Chart">
{#if chartRows.length > 0}
<Columns
format={(v) =>
formatMoney({
amount: v,
decimals: payments[0]?.decimals,
symbol: codes.length === 1 ? payments[0]?.symbol : null,
})}
height={220}
keys={chartKeys}
label="Paid per month"
rows={chartRows}
/>
{:else}
<p class="text-cladd-fg-soft">
{transactions.isPending
? "Loading transactions"
: "No transactions are linked to this subscription yet."}
</p>
{/if}
</Box>
<Box body="p-0" class="mt-3" title="Connected transactions">
<TransactionTable
empty="No transactions are linked to this subscription."
groups={transactions.data ?? []}
id="bill-transactions"
loading={transactions.isPending}
/>
</Box>
{/if}