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

@@ -0,0 +1,138 @@
<script lang="ts">
import { invalidate } from "#lib/api/lists.js";
import type { Item, PiggyBank } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { evalAmount } from "#lib/components/inputs/parse.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Status from "#lib/components/misc/Status.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { formatMoney } from "#lib/format.js";
import {
changeMoney,
limit,
type Mode,
piggyMoney,
type Room,
} from "./piggy.js";
let {
piggy,
mode,
rooms,
onclose,
}: {
piggy: Item<PiggyBank>;
mode: Mode;
rooms: Map<string, Room>;
onclose: () => void;
} = $props();
const action = new Action();
const values = $state<Record<string, string>>({});
const links = $derived(
(piggy.attributes.accounts ?? []).map((link) => {
const id = link.account_id ?? "";
return {
id,
max: limit(piggy.attributes, id, mode, rooms.get(id)),
name: link.name ?? id,
};
})
);
const open = $derived(links.filter((l) => l.max > 0));
function onEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onclose();
}
}
function fail(message: string) {
action.failed = true;
action.message = message;
}
async function save() {
const deltas: Record<string, number> = {};
for (const link of open) {
const raw = values[link.id]?.trim();
if (!raw) {
continue;
}
const amount = evalAmount(raw);
if (amount === null || amount <= 0) {
return fail("Enter an amount above zero");
}
if (amount > link.max + 1e-9) {
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
return fail(`${link.name} allows at most ${max}`);
}
deltas[link.id] = mode === "add" ? amount : -amount;
}
if (Object.keys(deltas).length === 0) {
return fail("Enter an amount");
}
const ok = await action.run(
() => changeMoney(piggy, deltas),
mode === "add" ? "Added" : "Removed"
);
if (ok) {
await invalidate();
onclose();
}
}
</script>
<form
class="flex flex-wrap items-center gap-x-4 gap-y-2"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<span class="font-semibold">
{mode === "add" ? "Add money to" : "Remove money from"}
{piggy.attributes.name}
</span>
{#each open as link, i (link.id)}
<div class="flex items-center gap-2">
{#if open.length > 1}
<span>{link.name}</span>
{/if}
<Input
aria-label="Amount for {link.name}"
class="num h-7 w-28"
inputmode="decimal"
onkeydown={onEscape}
bind:value={values[link.id]}
{@attach (node) => {
if (i === 0) {
node.focus();
}
}}
/>
<span class="text-[12px] text-cladd-fg-soft">
up to
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
</span>
</div>
{:else}
<span class="text-cladd-fg-soft">
{mode === "add"
? "You have no money to put in this piggy bank."
: "There is no money in this piggy bank."}
</span>
{/each}
{#if open.length > 0}
<Button disabled={action.busy} size="sm" type="submit">
{mode === "add" ? "Add" : "Remove"}
</Button>
{/if}
<Button onclick={onclose} size="sm" type="button" variant="ghost"
>Cancel <span class="kbd">Esc</span></Button
>
<Status {action} />
</form>