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,16 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import { Input } from "#lib/components/ui/input/index.js";
let { id, value = $bindable("") }: { id: string; value: string } = $props();
const groups = createQuery(() => lists.objectGroups());
</script>
<Input autocomplete="off" {id} list="{id}-list" bind:value />
<datalist id="{id}-list">
{#each groups.data ?? [] as group (group.id)}
<option value={group.attributes.title}></option>
{/each}
</datalist>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import Amount from "#lib/components/Amount.svelte";
import { formatMoney } from "#lib/format.js";
import type { Total } from "./object-groups.js";
let {
totals,
tone = "plain",
empty = "",
}: {
totals: Total[];
tone?: "income" | "expense" | "plain";
empty?: string;
} = $props();
</script>
{#each totals as total (total.code)}
<div>
{#if tone === "plain"}
<span class="num">{formatMoney(total)}</span>
{:else}
<Amount
amount={total.amount}
code={total.code}
decimals={total.decimals}
symbol={total.symbol}
{tone}
/>
{/if}
</div>
{:else}
{#if empty}
<span class="text-cladd-fg-softer">{empty}</span>
{/if}
{/each}
@@ -0,0 +1,95 @@
import { queryClient } from "#lib/api/lists.js";
import type { Item } from "#lib/api/types.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
interface Groupable {
object_group_id?: string | null;
object_group_order?: number | null;
object_group_title?: string | null;
order?: number;
}
export interface Section<T> {
id: string;
items: T[];
order: number;
title: string;
}
export const UNGROUPED = "(ungrouped)";
export function byObjectGroup<T extends Groupable>(
items: Item<T>[]
): Section<Item<T>>[] {
const sections = new Map<string, Section<Item<T>>>();
for (const item of items) {
const { object_group_id, object_group_order, object_group_title } =
item.attributes;
const id = object_group_id ?? "";
let section = sections.get(id);
if (!section) {
section = {
id,
items: [],
order: id ? (object_group_order ?? 0) : -1,
title: object_group_title ?? UNGROUPED,
};
sections.set(id, section);
}
section.items.push(item);
}
const list = [...sections.values()].sort((a, b) => a.order - b.order);
for (const section of list) {
section.items.sort(
(a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0)
);
}
return list;
}
export interface Total {
amount: number;
code: string;
decimals: number;
symbol: string;
}
export function sumByCurrency(entries: Money[]): Total[] {
const totals = new Map<string, Total>();
for (const entry of entries) {
const code = entry.code ?? "";
const total = totals.get(code) ?? {
amount: 0,
code,
decimals: entry.decimals ?? 2,
symbol: entry.symbol ?? code,
};
total.amount += toNumber(entry.amount);
totals.set(code, total);
}
return [...totals.values()];
}
export const LINK = "text-brand hover:underline dark:text-(--ff-series-1)";
export function swapOrder<T extends { order?: number }>(
key: readonly unknown[],
a: Item<T>,
b: Item<T>
): number | undefined {
const { order: first } = a.attributes;
const { order: second } = b.attributes;
queryClient.setQueryData<Item<T>[]>(key, (old) =>
old?.map((item) => {
if (item.id === a.id) {
return { ...item, attributes: { ...item.attributes, order: second } };
}
if (item.id === b.id) {
return { ...item, attributes: { ...item.attributes, order: first } };
}
return item;
})
);
return second;
}
@@ -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>
@@ -0,0 +1,282 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, One, PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import GroupInput from "#lib/components/groups/GroupInput.svelte";
import { amountString } from "#lib/components/inputs/parse.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 { select, textarea } from "#lib/components/misc/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
let { piggy }: { piggy?: Item<PiggyBank> } = $props();
const initial = untrack(() => piggy?.attributes);
const form = $state({
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
object_group_title: initial?.object_group_title ?? "",
start_date: initial?.start_date?.slice(0, 10) ?? today(),
target_amount: initial?.target_amount ?? "",
target_date: initial?.target_date?.slice(0, 10) ?? "",
});
const linked = $state<Record<string, string>>(
Object.fromEntries(
(initial?.accounts ?? []).map((a) => [
a.account_id ?? "",
a.current_amount ?? "0",
])
)
);
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const accounts = $derived(
[...(assets.data ?? []), ...(liabilities.data ?? [])].filter(
(a) =>
a.id in linked ||
(a.attributes.active &&
a.attributes.currency_code === form.currency_code)
)
);
const currencies = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
)
);
const decimals = $derived(
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
.decimal_places ?? 2
);
const action = new Action();
function toggle(id: string, on: boolean) {
if (on) {
linked[id] = "0";
} else {
delete linked[id];
}
}
const snapshot = (entries: [string, string][]) =>
JSON.stringify(
entries
.map(([id, amount]) => [id, amountString(amount, decimals) || "0"])
.sort()
);
const before = snapshot(Object.entries(linked));
function body() {
const entries = Object.entries(linked);
return {
accounts:
piggy && snapshot(entries) === before
? undefined
: entries.map(([id, amount]) => ({
account_id: id,
current_amount: amountString(amount, decimals) || "0",
})),
name: form.name.trim(),
notes: form.notes.trim() || null,
object_group_title:
form.object_group_title.trim() ||
(initial?.object_group_title ? "" : undefined),
start_date: form.start_date || today(),
target_amount: amountString(form.target_amount, decimals) || "0",
target_date: form.target_date || null,
transaction_currency_code:
form.currency_code === initial?.currency_code
? undefined
: form.currency_code,
};
}
async function save() {
let id = piggy?.id;
const ok = await action.run(async () => {
const saved = await api<One<PiggyBank>>(
piggy ? `/v1/piggy-banks/${piggy.id}` : "/v1/piggy-banks",
{ body: body(), method: piggy ? "PUT" : "POST" }
);
({ id } = saved.data);
});
if (ok) {
await invalidate();
await goto(`/piggy-banks/show/${id}`);
}
}
async function remove() {
if (!piggy) {
return;
}
const ok = await action.run(() =>
api(`/v1/piggy-banks/${piggy.id}`, { method: "DELETE" })
);
if (ok) {
await goto("/piggy-banks");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: piggy ? "Update piggy bank" : "Store new piggy bank",
run: save,
},
]);
</script>
<form
class="flex flex-col gap-3"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="piggy-name" label="Name" problem={action.field("name")}>
<Input id="piggy-name" required bind:value={form.name} />
</Field>
<Field
for="piggy-target"
help="Leave empty to save without a target."
label="Target amount"
problem={action.field("target_amount")}
>
<Input
class="num"
id="piggy-target"
inputmode="decimal"
bind:value={form.target_amount}
/>
</Field>
<Field
for="piggy-currency"
help="Piggy banks can only save money in a single currency."
label="Currency"
problem={action.field("transaction_currency_code")}
>
<select
class={select}
id="piggy-currency"
bind:value={form.currency_code}
>
{#each currencies as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
help="Only accounts in the selected currency are listed. The amount is what this piggy bank holds on each account."
label="Save on account(s)"
problem={action.field("accounts") ??
Object.entries(action.errors).find(([k]) =>
k.startsWith("accounts.")
)?.[1][0]}
>
<div
class="cladd-cut flex max-h-64 flex-col overflow-y-auto rounded-cladd-md py-1"
>
{#each accounts as account (account.id)}
{@const on = account.id in linked}
<div class="flex h-8 items-center gap-2 px-2.5">
<label class="flex min-w-0 flex-1 items-center gap-2">
<input
checked={on}
class="size-4 accent-(--ff-brand)"
onchange={(e) => toggle(account.id, e.currentTarget.checked)}
type="checkbox"
>
<span class="truncate">{account.attributes.name}</span>
<span class="text-[12px] text-cladd-fg-softer"
>{account.attributes.currency_code}</span
>
</label>
{#if on}
<Input
aria-label="Saved on {account.attributes.name}"
class="num h-7 w-28"
inputmode="decimal"
bind:value={linked[account.id]}
/>
{/if}
</div>
{:else}
<p class="px-2.5 py-1.5 text-cladd-fg-soft">
{assets.isPending
? "Loading accounts"
: "No asset account or liability uses this currency."}
</p>
{/each}
</div>
</Field>
</Box>
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
{#if piggy}
<Field
for="piggy-start"
label="Start date"
problem={action.field("start_date")}
>
<Input id="piggy-start" type="date" bind:value={form.start_date} />
</Field>
{/if}
<Field
for="piggy-target-date"
label="Target date"
problem={action.field("target_date")}
>
<Input
id="piggy-target-date"
type="date"
bind:value={form.target_date}
/>
</Field>
<Field
for="piggy-group"
label="Group"
problem={action.field("object_group_title")}
>
<GroupInput id="piggy-group" bind:value={form.object_group_title} />
</Field>
<Field for="piggy-notes" label="Notes" problem={action.field("notes")}>
<textarea
class={textarea}
id="piggy-notes"
rows="4"
bind:value={form.notes}
></textarea>
</Field>
</Box>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button disabled={action.busy} type="submit">
{piggy ? "Update piggy bank" : "Store new piggy bank"}
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
{#if piggy}
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/if}
</div>
</form>
+94
View File
@@ -0,0 +1,94 @@
import { api } from "#lib/api/client.js";
import type { Account, Item, One, PiggyBank } from "#lib/api/types.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
export type Mode = "add" | "remove";
export interface Room {
account: Item<Account> | undefined;
balance: number;
earmarked: number;
free: number;
}
export function rooms(
piggies: Item<PiggyBank>[],
accounts: Item<Account>[]
): Map<string, Room> {
const byId = new Map(accounts.map((a) => [a.id, a]));
const result = new Map<string, Room>();
for (const piggy of piggies) {
for (const link of piggy.attributes.accounts ?? []) {
const id = link.account_id ?? "";
const account = byId.get(id);
const room = result.get(id) ?? {
account,
balance: toNumber(account?.attributes.current_balance),
earmarked: 0,
free: 0,
};
room.earmarked += toNumber(link.current_amount);
room.free = room.balance - room.earmarked;
result.set(id, room);
}
}
return result;
}
export function limit(
piggy: PiggyBank,
accountId: string,
mode: Mode,
room: Room | undefined
): number {
const link = piggy.accounts?.find((a) => a.account_id === accountId);
if (mode === "remove") {
return toNumber(link?.current_amount);
}
const free = Math.max(0, room?.free ?? 0);
const target = toNumber(piggy.target_amount);
return target > 0 ? Math.min(free, toNumber(piggy.left_to_save)) : free;
}
export const piggyMoney = (piggy: PiggyBank, amount: unknown): Money => ({
amount: amount as string,
code: piggy.currency_code,
decimals: piggy.currency_decimal_places,
symbol: piggy.currency_symbol,
});
export async function changeMoney(
piggy: Item<PiggyBank>,
deltas: Record<string, number>
): Promise<Item<PiggyBank>> {
const decimals = piggy.attributes.currency_decimal_places ?? 2;
const accounts = (piggy.attributes.accounts ?? []).map((link) => ({
account_id: link.account_id,
current_amount: (
toNumber(link.current_amount) + (deltas[link.account_id ?? ""] ?? 0)
).toFixed(decimals),
}));
const saved = await api<One<PiggyBank>>(`/v1/piggy-banks/${piggy.id}`, {
body: { accounts },
method: "PUT",
});
const after = new Map(
(saved.data.attributes.accounts ?? []).map((a) => [
a.account_id,
toNumber(a.current_amount),
])
);
const refused = accounts.find(
(a) =>
!after.has(a.account_id) ||
Math.abs((after.get(a.account_id) ?? 0) - toNumber(a.current_amount)) >
10 ** -decimals / 2
);
if (refused) {
throw new Error(
"Firefly kept the old amount: there is not enough money on the account"
);
}
return saved.data;
}
+1 -1
View File
@@ -34,7 +34,7 @@
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
{/if}
</div>
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center gap-3">
{@render actions?.()}
<ol
aria-label="Breadcrumbs"
@@ -0,0 +1,261 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Bill, Item, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import GroupInput from "#lib/components/groups/GroupInput.svelte";
import { amountString } from "#lib/components/inputs/parse.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 { select, textarea } from "#lib/components/misc/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { FREQUENCIES, FREQUENCY_LABELS } from "./bills.js";
let { bill }: { bill?: Item<Bill> } = $props();
const initial = untrack(() => bill?.attributes);
const form = $state({
active: initial?.active ?? true,
amount_max: initial?.amount_max ?? "",
amount_min: initial?.amount_min ?? "",
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
date: initial?.date?.slice(0, 10) ?? today(),
end_date: initial?.end_date?.slice(0, 10) ?? "",
extension_date: initial?.extension_date?.slice(0, 10) ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
object_group_title: initial?.object_group_title ?? "",
repeat_freq: initial?.repeat_freq ?? "monthly",
skip: String(initial?.skip ?? 0),
});
const action = new Action();
const currencies = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
)
);
const decimals = $derived(
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
.decimal_places ?? 2
);
function body() {
const min = amountString(form.amount_min, decimals);
return {
active: form.active,
amount_max: amountString(form.amount_max, decimals) || min,
amount_min: min,
currency_code:
form.currency_code === initial?.currency_code
? undefined
: form.currency_code,
date: form.date,
end_date: form.end_date || undefined,
extension_date: form.extension_date || undefined,
name: form.name.trim(),
notes: form.notes.trim() || null,
object_group_title:
form.object_group_title.trim() || (bill ? "" : undefined),
repeat_freq: form.repeat_freq,
skip: Number(form.skip) || 0,
};
}
async function save() {
let id = bill?.id;
const ok = await action.run(async () => {
const saved = await api<One<Bill>>(
bill ? `/v1/bills/${bill.id}` : "/v1/bills",
{ body: body(), method: bill ? "PUT" : "POST" }
);
({ id } = saved.data);
});
if (ok) {
await invalidate();
await goto(`/subscriptions/show/${id}`);
}
}
async function remove() {
if (!bill) {
return;
}
const ok = await action.run(() =>
api(`/v1/bills/${bill.id}`, { method: "DELETE" })
);
if (ok) {
await goto("/subscriptions");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: bill ? "Update subscription" : "Store new subscription",
run: save,
},
]);
</script>
<form
class="flex flex-col gap-3"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="bill-name" label="Name" problem={action.field("name")}>
<Input id="bill-name" required bind:value={form.name} />
</Field>
<Field
for="bill-currency"
label="Currency"
problem={action.field("currency_code") ?? action.field("currency_id")}
>
<select
class={select}
id="bill-currency"
bind:value={form.currency_code}
>
{#each currencies as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
for="bill-min"
label="Minimum amount"
problem={action.field("amount_min")}
>
<Input
class="num"
id="bill-min"
inputmode="decimal"
required
bind:value={form.amount_min}
/>
</Field>
<Field
for="bill-max"
help="Leave empty when the amount is always the same."
label="Maximum amount"
problem={action.field("amount_max")}
>
<Input
class="num"
id="bill-max"
inputmode="decimal"
bind:value={form.amount_max}
/>
</Field>
<Field
for="bill-date"
help="The first date this subscription is expected."
label="Date"
problem={action.field("date")}
>
<Input id="bill-date" required type="date" bind:value={form.date} />
</Field>
<Field
for="bill-repeat"
label="Repeats"
problem={action.field("repeat_freq")}
>
<select class={select} id="bill-repeat" bind:value={form.repeat_freq}>
{#each FREQUENCIES as frequency (frequency)}
<option value={frequency}>{FREQUENCY_LABELS[frequency]}</option>
{/each}
</select>
</Field>
<Field
for="bill-skip"
help="Skip 1 with monthly means every other month."
label="Skip"
problem={action.field("skip")}
>
<Input
class="num w-24"
id="bill-skip"
max="31"
min="0"
type="number"
bind:value={form.skip}
/>
</Field>
</Box>
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
<Field
for="bill-end"
help="The subscription stops being expected after this date."
label="End date"
problem={action.field("end_date")}
>
<Input id="bill-end" type="date" bind:value={form.end_date} />
</Field>
<Field
for="bill-extension"
help="The date to extend or cancel the subscription."
label="Extension date"
problem={action.field("extension_date")}
>
<Input
id="bill-extension"
type="date"
bind:value={form.extension_date}
/>
</Field>
<Field
for="bill-group"
label="Group"
problem={action.field("object_group_title")}
>
<GroupInput id="bill-group" bind:value={form.object_group_title} />
</Field>
<Field for="bill-notes" label="Notes" problem={action.field("notes")}>
<textarea
class={textarea}
id="bill-notes"
rows="4"
bind:value={form.notes}
></textarea>
</Field>
{#if bill}
<Field for="bill-active" label="Active">
<input
class="mt-2 size-4 accent-(--ff-brand)"
id="bill-active"
type="checkbox"
bind:checked={form.active}
>
</Field>
{/if}
</Box>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button disabled={action.busy} type="submit">
{bill ? "Update subscription" : "Store new subscription"}
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
{#if bill}
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/if}
</div>
</form>
@@ -0,0 +1,20 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import { STATUS_TONE, type Status, statusText } from "./bills.js";
let { state }: { state: Status } = $props();
</script>
{#if state.kind === "paid"}
<span class="inline-flex flex-wrap gap-x-2">
{#each state.paid as paid (paid.transaction_journal_id)}
<a
class="text-income hover:underline"
href="/transactions/show/{paid.transaction_group_id}"
>✓ {formatDate(paid.date, "short")}</a
>
{/each}
</span>
{:else}
<span class={STATUS_TONE[state.kind]}>{statusText(state)}</span>
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Cell } from "./bills.js";
let { cells, months }: { cells: Cell[]; months: string[] } = $props();
const LOOK: Record<Cell, string> = {
due: "border border-warn",
idle: "border border-cladd-fg-softest",
missed: "border border-expense bg-expense/15",
off: "bg-cladd-fg-softest/25 scale-50",
paid: "bg-income",
};
const TITLE: Record<Cell, string> = {
due: "expected, not paid yet",
idle: "expected",
missed: "expected, not paid",
off: "not expected",
paid: "paid",
};
</script>
<span class="inline-flex gap-[3px] align-middle">
{#each cells as cell, i (months[i])}
<span
class="size-2.5 rounded-[2px] {LOOK[cell]}"
title="{months[i]}: {TITLE[cell]}"
></span>
{/each}
</span>
+176
View File
@@ -0,0 +1,176 @@
import type { Bill, Item } from "#lib/api/types.js";
import { addMonths, daysBetween, type Range, today } from "#lib/dates.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
export const FREQUENCIES = [
"weekly",
"monthly",
"quarterly",
"half-year",
"yearly",
] as const;
export type Frequency = (typeof FREQUENCIES)[number];
const PER_YEAR: Record<Frequency, number> = {
"half-year": 2,
monthly: 12,
quarterly: 4,
weekly: 52.17,
yearly: 1,
};
const MONTHS_PER: Record<Frequency, number> = {
"half-year": 6,
monthly: 1,
quarterly: 3,
weekly: 0,
yearly: 12,
};
const UNIT: Record<Frequency, [string, string]> = {
"half-year": ["every half year", "half years"],
monthly: ["monthly", "months"],
quarterly: ["quarterly", "quarters"],
weekly: ["weekly", "weeks"],
yearly: ["yearly", "years"],
};
export const FREQUENCY_LABELS: Record<Frequency, string> = {
"half-year": "Every half year",
monthly: "Monthly",
quarterly: "Quarterly",
weekly: "Weekly",
yearly: "Yearly",
};
const freq = (bill: Bill): Frequency =>
(bill.repeat_freq ?? "monthly") as Frequency;
export const average = (bill: Bill) =>
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
export const timesPerYear = (bill: Bill) =>
PER_YEAR[freq(bill)] / ((bill.skip ?? 0) + 1);
export function repeats(bill: Bill): string {
const [single, plural] = UNIT[freq(bill)];
const every = (bill.skip ?? 0) + 1;
return every > 1 ? `every ${every} ${plural}` : single;
}
export const money = (bill: Bill, amount: number): Money => ({
amount,
code: bill.currency_code,
decimals: bill.currency_decimal_places,
symbol: bill.currency_symbol,
});
export const monthly = (bill: Bill) =>
money(bill, (average(bill) * timesPerYear(bill)) / 12);
export const yearly = (bill: Bill) =>
money(bill, average(bill) * timesPerYear(bill));
export const dueIn = (range: Range, bill: Bill) =>
(bill.pay_dates ?? [])
.map((date) => date.slice(0, 10))
.filter((date) => date >= range.start && date <= range.end);
export type Status =
| { kind: "inactive" }
| { kind: "paid"; paid: NonNullable<Bill["paid_dates"]> }
| { kind: "due" | "overdue"; date: string; days: number }
| { kind: "none" };
export function status(bill: Bill, range: Range): Status {
if (!bill.active) {
return { kind: "inactive" };
}
const paid = bill.paid_dates ?? [];
if (paid.length > 0) {
return { kind: "paid", paid };
}
const [date] = dueIn(range, bill);
if (!date) {
return { kind: "none" };
}
const days = daysBetween(today(), date);
return { date, days, kind: days < 0 ? "overdue" : "due" };
}
export function statusText(state: Status): string {
switch (state.kind) {
case "inactive":
return "Inactive";
case "none":
return "Not expected this period";
case "overdue":
return `Overdue ${-state.days} ${-state.days === 1 ? "day" : "days"}`;
case "due":
if (state.days === 0) {
return "Due today";
}
return `Due in ${state.days} ${state.days === 1 ? "day" : "days"}`;
default:
return "Paid";
}
}
export const STATUS_TONE: Record<Status["kind"], string> = {
due: "text-warn",
inactive: "text-cladd-fg-softer",
none: "text-cladd-fg-softer",
overdue: "text-expense",
paid: "text-income",
};
export const nextExpected = (bill: Bill) =>
bill.pay_dates?.[0]?.slice(0, 10) ?? bill.next_expected_match ?? null;
export type Cell = "paid" | "missed" | "due" | "off" | "idle";
export function expectedIn(bill: Bill, month: string): boolean {
const start = (bill.date ?? "").slice(0, 7);
const end = bill.end_date?.slice(0, 7);
if (!start || month < start || (end && month > end)) {
return false;
}
const step = MONTHS_PER[freq(bill)] * ((bill.skip ?? 0) + 1);
if (step === 0) {
return true;
}
const [y1, m1] = start.split("-").map(Number);
const [y2, m2] = month.split("-").map(Number);
return ((y2 - y1) * 12 + (m2 - m1)) % step === 0;
}
export function lastMonths(end: string, count = 12): string[] {
const first = addMonths(`${end.slice(0, 7)}-01`, 1 - count);
return Array.from({ length: count }, (_, i) =>
addMonths(first, i).slice(0, 7)
);
}
export function strip(bill: Bill, months: string[]): Cell[] {
const paid = new Set((bill.paid_dates ?? []).map((p) => p.date?.slice(0, 7)));
const day = today();
const now = day.slice(0, 7);
const late = (bill.pay_dates ?? []).some(
(date) => date.slice(0, 7) === now && date.slice(0, 10) < day
);
return months.map((month) => {
if (paid.has(month)) {
return "paid";
}
if (!(bill.active && expectedIn(bill, month))) {
return "off";
}
if (month > now) {
return "idle";
}
return month === now && !late ? "due" : "missed";
});
}
export const billKey = (item: Item<Bill>) => item.id;