feat(subscriptions): cash runway per card, price changes, stale bills, unlinked payments

The page opens with how much each paying card needs by a date and where
it falls short, a 30-day calendar per card and the annual renewals ahead.
The table shows price changes, the paying card, stale subscriptions to
deactivate in place and payments that look like a subscription, L links
them.
This commit is contained in:
hh committed 2026-10-16 15:13:35 +00:00
1 parent 153a8cb191
commit 68f64893f7
10 files changed
+1425 -105

No files matched your search

@@ -0,0 +1,136 @@
<script lang="ts">
import { addDays, parse } from "#lib/dates.js";
import { compactMoney, currentLocale, formatDate } from "#lib/format.js";
import { type Need, STRIP_DAYS, UNKNOWN } from "#lib/subscriptions/runway.js";
import Money from "./Money.svelte";
let { needs, day, end }: { needs: Need[]; day: string; end: string } =
$props();
const THOUSAND = 1000;
const SUNDAY = 0;
const SATURDAY = 6;
const days = $derived(
Array.from({ length: STRIP_DAYS + 1 }, (_, i) => addDays(day, i))
);
const rows = $derived(
needs.map((need) => {
const main = need.currency;
let running = 0;
const cells = days.map((date) => {
const list = need.dues.filter((d) => d.date === date);
const code = main?.code ?? list[0]?.pay.code;
const amount = list
.filter((d) => d.pay.code === code)
.reduce((acc, d) => acc + d.pay.amount, 0);
running += amount;
return {
amount,
count: list.length,
date,
fill: need.balance ? Math.min(1, running / need.balance) : 0,
label: compactMoney(amount < THOUSAND ? Math.round(amount) : amount),
names: list.map((d) => d.bill.attributes.name).join(", "),
over: need.short !== null && date >= need.short.date,
};
});
return { cells, need };
})
);
function dayTone(date: string, i: number) {
if (i === 0) {
return "font-semibold text-cladd-fg";
}
return [SUNDAY, SATURDAY].includes(parse(date).getDay())
? "text-cladd-fg-softest"
: "text-cladd-fg-softer";
}
const month = $derived(
new Intl.DateTimeFormat(currentLocale(), { month: "short" })
);
const monthMark = (date: string, i: number) =>
i === 0 || date.endsWith("-01") ? month.format(parse(date)) : "";
</script>
<div class="overflow-x-auto">
<div
class="grid min-w-[60rem] pb-2 text-[11px]"
style:grid-template-columns="minmax(8rem, 11rem) repeat({days.length}, minmax(1.5rem, 1fr)) minmax(6.5rem, 8rem)"
>
<div class="sticky left-0 bg-cladd-surface"></div>
{#each days as date, i (date)}
<div
class="ps-0.5 pt-2 font-semibold text-cladd-fg-soft {date > end
? "opacity-50"
: ""}"
>
{monthMark(date, i)}
</div>
{/each}
<div></div>
<div class="sticky left-0 bg-cladd-surface"></div>
{#each days as date, i (date)}
<div
class="num pb-1 text-center {dayTone(date, i)} {date > end
? "opacity-50"
: ""}"
>
{Number(date.slice(8))}
</div>
{/each}
<div class="pe-3 pb-1 text-right text-cladd-fg-softer">
by {formatDate(days.at(-1), "short")}
</div>
{#each rows as row (row.need.id)}
<div
class="sticky left-0 z-10 flex h-7 items-center truncate border-cladd-outline/60 border-t bg-cladd-surface ps-3 pe-2 font-medium text-[12px] {row
.need.id === UNKNOWN
? "text-warn"
: "text-cladd-fg"}"
>
<span class="truncate">{row.need.name}</span>
</div>
{#each row.cells as cell (cell.date)}
<div
class="relative h-7 border-cladd-outline/60 border-t border-l {cell.date >
end
? "opacity-50"
: ""} {cell.over && cell.date === row.need.short?.date
? "outline-2 outline-expense -outline-offset-2"
: ""}"
title={cell.names
? `${formatDate(cell.date, "day")}: ${cell.names}`
: undefined}
>
<div
class="absolute inset-x-0 bottom-0 {cell.over
? "bg-expense/25"
: "bg-brand/15"}"
style:height="{cell.over ? 100 : cell.fill * 100}%"
></div>
{#if cell.count}
<span
class="num relative flex h-full items-center justify-center font-semibold {cell.over
? "text-expense"
: "text-cladd-fg"}"
>{cell.label}</span
>
{/if}
</div>
{/each}
<div
class="flex h-7 items-center justify-end gap-1 whitespace-nowrap border-cladd-outline/60 border-t border-l pe-3 font-semibold text-[12px] {row
.need.short
? "text-expense"
: "text-cladd-fg"}"
>
{#each row.need.totals as total, i (total.code)}
{i > 0 ? "+" : ""}<Money {...total} />
{/each}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
import type { Card } from "#lib/subscriptions/runway.js";
let { billId, cards }: { billId: string; cards: Card[] } = $props();
const chosen = $derived(manualCards.get(billId));
</script>
<ContextMenuSub>
<ContextMenuSubTrigger>Paying card</ContextMenuSubTrigger>
<ContextMenuSubContent class="max-h-80 w-60">
{#each cards as card (card.id)}
<ContextMenuItem onSelect={() => manualCards.set(billId, card.id)}>
<span class="flex-1 truncate">{card.name}</span>
<span class="text-[11px] text-cladd-fg-softer">{card.code}</span>
{#if chosen === card.id}
<Check class="size-3.5" />
{/if}
</ContextMenuItem>
{/each}
<ContextMenuSeparator />
<ContextMenuItem
disabled={!chosen}
onSelect={() => manualCards.set(billId, null)}
>Card of the last payment</ContextMenuItem
>
</ContextMenuSubContent>
</ContextMenuSub>
@@ -0,0 +1,52 @@
<script lang="ts">
import SuggestInput from "#lib/components/transactions/SuggestInput.svelte";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
import type { Card } from "#lib/subscriptions/runway.js";
import type { Suggestion } from "#lib/transactions/query.js";
const AUTO = "\u0000";
let { billId, cards }: { billId: string; cards: Card[] } = $props();
let text = $state("");
let input = $state<HTMLInputElement>();
const suggestions = $derived.by<Suggestion[]>(() => {
const found = fuzzyFilter(cards, text, (c) => c.name, 20).map((c) => ({
hint: c.code,
id: c.id,
insert: c.id,
label: c.name,
}));
return text.trim()
? found
: [
{ id: AUTO, insert: AUTO, label: "Card of the last payment" },
...found,
];
});
$effect(() => {
input?.focus();
});
const cancel = () => (manualCards.picking = "");
</script>
<SuggestInput
aria-label="Paying card"
class="w-56"
id="bill-card-{billId}"
onaccept={(item) => {
manualCards.set(billId, item.insert === AUTO ? null : item.insert);
return true;
}}
onblur={cancel}
oncancel={cancel}
onsubmit={cancel}
placeholder="Paying card"
{suggestions}
bind:input
bind:value={text}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { formatNumber, toNumber } from "#lib/format.js";
let {
amount,
symbol,
decimals,
approx = false,
class: className = "",
}: {
amount: number | string | null | undefined;
symbol?: string | null;
decimals?: number | null;
approx?: boolean;
class?: string;
} = $props();
</script>
<span class="num {className}"
><span class="font-normal text-cladd-fg-softer"
>{approx ? "~" : ""}{symbol ?? ""}</span
>{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)}</span
>
@@ -0,0 +1,249 @@
<script lang="ts">
import type { Bill, Item } from "#lib/api/types.js";
import { LINK } from "#lib/components/groups/object-groups.js";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { currentLocale, formatDate } from "#lib/format.js";
import {
type Card,
type Due,
HORIZONS,
type Horizon,
type Need,
RENEWAL_DAYS,
sum,
UNKNOWN,
} from "#lib/subscriptions/runway.js";
import { goto } from "$app/navigation";
import Calendar from "./Calendar.svelte";
import CardMenu from "./CardMenu.svelte";
import Money from "./Money.svelte";
let {
needs,
strip,
renewals,
cards,
day,
end,
horizon,
byCurrency,
status,
onhorizon,
onmode,
}: {
needs: Need[];
strip: Need[];
renewals: Due[];
cards: Card[];
day: string;
end: string;
horizon: Horizon;
byCurrency: boolean;
status: string;
onhorizon: (horizon: Horizon) => void;
onmode: () => void;
} = $props();
let menuFor = $state<Item<Bill> | null>(null);
const monthName = $derived(
new Intl.DateTimeFormat(currentLocale(), { month: "long" }).format(
new Date(`${day}T12:00:00`)
)
);
const label = (id: Horizon, text: string) =>
id === "month" ? `Rest of ${monthName}` : text;
const renewalTotals = $derived(sum(renewals.map((d) => d.native)));
function oncontextmenu(event: MouseEvent) {
const id = (event.target as Element)
.closest("[data-bill]")
?.getAttribute("data-bill");
menuFor =
needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null;
}
const segment = (on: boolean) =>
buttonVariants({ size: "xs", variant: on ? "default" : "outline" });
</script>
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
<header
class="flex flex-wrap items-center gap-x-4 gap-y-2 border-cladd-outline border-b px-3 py-2"
>
<h2 class="me-auto font-semibold text-[15px] text-cladd-fg">
Needed by {formatDate(end, "day")}
</h2>
<fieldset class="flex flex-wrap gap-1">
<legend class="sr-only">Horizon</legend>
{#each HORIZONS as h (h.id)}
<button
aria-pressed={horizon === h.id}
class={segment(horizon === h.id)}
onclick={() => onhorizon(h.id)}
type="button"
>
{label(h.id, h.label)}<span class="kbd max-sm:hidden">{h.key}</span>
</button>
{/each}
</fieldset>
<fieldset class="flex gap-1">
<legend class="sr-only">Group by</legend>
<button
aria-pressed={!byCurrency}
class={segment(!byCurrency)}
onclick={() => byCurrency && onmode()}
type="button"
>
By card
</button>
<button
aria-pressed={byCurrency}
class={segment(byCurrency)}
onclick={() => byCurrency || onmode()}
type="button"
>
By currency<span class="kbd max-sm:hidden">p</span>
</button>
</fieldset>
</header>
{#if status}
<p class="px-3 py-6 text-cladd-fg-soft">{status}</p>
{:else if needs.length === 0}
<p class="px-3 py-4 text-cladd-fg-soft">
No subscription is due by {formatDate(end, "day")}.
</p>
{:else}
<ContextMenu>
<ContextMenuTrigger {oncontextmenu}>
<div
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
>
{#each needs as need (need.id)}
<article
class="flex flex-col rounded-cladd-md border px-3 py-2.5 {need.short
? "border-expense"
: "border-cladd-outline"}"
>
<h3
class="truncate font-semibold text-[13px] {need.id === UNKNOWN
? "text-warn"
: "text-cladd-fg"}"
>
{need.name}
</h3>
{#each need.totals as total (total.code)}
<div
class="font-semibold text-[26px] text-cladd-fg leading-tight"
>
<Money {...total} />
</div>
{/each}
{#if need.short && need.currency}
<div class="font-semibold text-[14px] text-expense">
Short
<Money
amount={need.short.amount}
decimals={need.currency.decimals}
symbol={need.currency.symbol}
/>
on {formatDate(need.short.date, "short")}
</div>
{/if}
{#if need.balance !== null && need.currency}
<div class="text-cladd-fg-soft">
{byCurrency ? "On paying cards" : "Balance"}
<Money
amount={need.balance}
class="text-cladd-fg"
decimals={need.currency.decimals}
symbol={need.currency.symbol}
/>
</div>
{/if}
<ul class="mt-2 border-cladd-outline border-t pt-1 text-[12px]">
{#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)}
<li
class="flex h-6 items-center gap-2"
data-bill={due.bill.id}
>
<span
class="num w-12 shrink-0 {due.overdue
? "text-expense"
: "text-cladd-fg-soft"}"
>{due.overdue
? "overdue"
: formatDate(due.date, "short")}</span
>
<a
class="min-w-0 flex-1 truncate text-cladd-fg hover:underline"
href="/subscriptions/show/{due.bill.id}"
>{due.bill.attributes.name}</a
>
<Money
{...due.pay}
approx={due.approx}
class="font-medium text-cladd-fg"
/>
</li>
{/each}
</ul>
</article>
{/each}
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem
onSelect={() => goto(`/subscriptions/show/${target.id}`)}
>Show</ContextMenuItem
>
<ContextMenuItem
onSelect={() => goto(`/subscriptions/edit/${target.id}`)}
>Edit</ContextMenuItem
>
<ContextMenuSeparator />
<CardMenu billId={target.id} {cards} />
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#if !status && renewals.length > 0}
<p
class="flex flex-wrap items-baseline gap-x-2 border-cladd-outline border-t px-3 py-2"
>
<span class="font-semibold text-cladd-fg"
>Annual renewals in {RENEWAL_DAYS} days:</span
>
<span class="font-semibold text-cladd-fg"
>{renewals.length},
{#each renewalTotals as total, i (total.code)}
{i > 0 ? " + " : ""}<Money {...total} />
{/each}</span
>
{#each renewals as due (due.bill.id)}
<span class="text-cladd-fg-soft"
><a class={LINK} href="/subscriptions/show/{due.bill.id}"
>{due.bill.attributes.name}</a
>
{formatDate(due.date, "short")}</span
>
{/each}
</p>
{/if}
{#if !status && strip.length > 0}
<div class="border-cladd-outline border-t">
<Calendar {day} {end} needs={strip} />
</div>
{/if}
</section>
+24 -1
View File
@@ -1,5 +1,11 @@
import type { Bill, Item } from "#lib/api/types.js";
import { addMonths, daysBetween, type Range, today } from "#lib/dates.js";
import {
addDays,
addMonths,
daysBetween,
type Range,
today,
} from "#lib/dates.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
@@ -47,6 +53,23 @@ export const FREQUENCY_LABELS: Record<Frequency, string> = {
const freq = (bill: Bill): Frequency =>
(bill.repeat_freq ?? "monthly") as Frequency;
const DAYS_PER_MONTH = 30.4;
const WEEK = 7;
export const cycleDays = (bill: Bill) =>
(MONTHS_PER[freq(bill)] * DAYS_PER_MONTH || WEEK) * ((bill.skip ?? 0) + 1);
export function twoCyclesAfter(bill: Bill, from: string): string {
const cycles = 2 * ((bill.skip ?? 0) + 1);
const months = MONTHS_PER[freq(bill)];
return months === 0
? addDays(from, WEEK * cycles)
: addMonths(from, months * cycles);
}
export const isRange = (bill: Bill) =>
toNumber(bill.amount_min) !== toNumber(bill.amount_max);
export const average = (bill: Bill) =>
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
+40
View File
@@ -0,0 +1,40 @@
const PREFIX = "ffui.bill-card.";
function read(): Record<string, string> {
const ids: Record<string, string> = {};
if (typeof localStorage === "undefined") {
return ids;
}
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key?.startsWith(PREFIX)) {
ids[key.slice(PREFIX.length)] = localStorage.getItem(key) ?? "";
}
}
return ids;
}
class ManualCards {
ids = $state<Record<string, string>>(read());
picking = $state("");
get(billId: string): string | null {
return this.ids[billId] || null;
}
set(billId: string, cardId: string | null) {
if (cardId) {
localStorage.setItem(PREFIX + billId, cardId);
} else {
localStorage.removeItem(PREFIX + billId);
}
this.ids = Object.fromEntries(
Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter(
([, id]) => id
)
);
this.picking = "";
}
}
export const manualCards = new ManualCards();
+46
View File
@@ -0,0 +1,46 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Bill, Item, Page, TransactionGroup } from "#lib/api/types.js";
import { addDays } from "#lib/dates.js";
import { toPayments } from "./runway.js";
const SEARCH_LIMIT = 500;
const UNLINKED_DAYS = 92;
async function search(query: string): Promise<Item<TransactionGroup>[]> {
const read = (page: number) =>
api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: SEARCH_LIMIT, page, query },
});
const first = await read(1);
const pages = first.meta.pagination?.total_pages ?? 1;
const rest = await Promise.all(
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => read(i + 2))
);
const seen = new Map<string, Item<TransactionGroup>>();
for (const group of [first, ...rest].flatMap((page) => page.data)) {
seen.set(group.id, group);
}
return [...seen.values()];
}
export const runwayQueries = {
bills: (start: string, end: string) => ({
queryFn: () => all<Bill>("/v1/bills", { end, start }),
queryKey: ["subscriptions", "runway", start, end],
}),
paid: () => ({
queryFn: async () => toPayments(await search("has_any_bill:true")),
queryKey: ["subscriptions", "payments"],
}),
unlinked: (day: string) => {
const after = addDays(day, -UNLINKED_DAYS);
return {
queryFn: async () =>
toPayments(
await search(`has_no_bill:true type:withdrawal date_after:${after}`)
).filter((payment) => !payment.billId),
queryKey: ["subscriptions", "unlinked", after],
};
},
};
+414
View File
@@ -0,0 +1,414 @@
import type { Account, Bill, Item, TransactionGroup } from "#lib/api/types.js";
import {
average,
cycleDays,
isRange,
twoCyclesAfter,
} from "#lib/components/subscriptions/bills.js";
import { addDays, addMonths, daysBetween } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
const EARLY_SHARE = 3;
const CENT = 0.005;
export interface Currency {
code: string;
decimals: number;
symbol: string;
}
export interface Sum extends Currency {
amount: number;
}
export interface Payment extends Currency {
amount: number;
billId: string;
date: string;
description: string;
destinationId: string;
foreign: number;
foreignCode: string;
groupId: string;
journalId: string;
sourceId: string;
sourceName: string;
}
export interface Card extends Currency {
balance: number | null;
id: string;
name: string;
}
export interface Payer {
card: Card | null;
last: Payment | undefined;
manual: boolean;
}
export interface Due {
approx: boolean;
bill: Item<Bill>;
card: Card | null;
date: string;
native: Sum;
overdue: boolean;
pay: Sum;
}
export interface Need {
balance: number | null;
currency: Currency | null;
dues: Due[];
id: string;
name: string;
short: { amount: number; date: string } | null;
totals: Sum[];
}
export const HORIZONS = [
{ id: "7", key: "1", label: "7 days" },
{ id: "14", key: "2", label: "14 days" },
{ id: "month", key: "3", label: "Rest of month" },
{ id: "30", key: "4", label: "30 days" },
] as const;
export type Horizon = (typeof HORIZONS)[number]["id"];
export const UNKNOWN = "unknown";
export const STRIP_DAYS = 30;
export const RENEWAL_DAYS = 60;
export function horizonEnd(horizon: Horizon, day: string): string {
if (horizon === "month") {
return addDays(addMonths(`${day.slice(0, 7)}-01`, 1), -1);
}
return addDays(day, Number(horizon));
}
export const billCurrency = (bill: Bill): Currency => ({
code: bill.currency_code ?? "",
decimals: bill.currency_decimal_places ?? 2,
symbol: bill.currency_symbol ?? bill.currency_code ?? "",
});
export function toPayments(groups: Item<TransactionGroup>[]): Payment[] {
return groups
.flatMap((group) =>
group.attributes.transactions.map((split) => ({
amount: Math.abs(toNumber(split.amount)),
billId: split.bill_id ?? "",
code: split.currency_code ?? "",
date: split.date.slice(0, 10),
decimals: split.currency_decimal_places ?? 2,
description: split.description,
destinationId: split.destination_id ?? "",
foreign: Math.abs(toNumber(split.foreign_amount)),
foreignCode: split.foreign_currency_code ?? "",
groupId: group.id,
journalId: split.transaction_journal_id ?? "",
sourceId: split.source_id ?? "",
sourceName: split.source_name ?? "",
symbol: split.currency_symbol ?? split.currency_code ?? "",
}))
)
.sort((a, b) => b.date.localeCompare(a.date));
}
export function byBill(payments: Payment[]): Map<string, Payment[]> {
const map = new Map<string, Payment[]>();
for (const payment of payments) {
map.set(payment.billId, [...(map.get(payment.billId) ?? []), payment]);
}
return map;
}
export const toCards = (accounts: Item<Account>[]) =>
new Map<string, Card>(
accounts.map(({ id, attributes: a }) => [
id,
{
balance: toNumber(a.current_balance),
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
id,
name: a.name,
symbol: a.currency_symbol ?? a.currency_code ?? "",
},
])
);
export function payerOf(
last: Payment | undefined,
cards: Map<string, Card>,
manual: string | null
): Payer {
const chosen = manual ? cards.get(manual) : undefined;
if (chosen) {
return { card: chosen, last, manual: true };
}
if (!last) {
return { card: null, last, manual: false };
}
const card = cards.get(last.sourceId) ?? {
balance: null,
code: last.code,
decimals: last.decimals,
id: last.sourceId,
name: last.sourceName,
symbol: last.symbol,
};
return { card, last, manual: false };
}
export function ownRates(payments: Payment[]): Map<string, number> {
const rates = new Map<string, number>();
for (const p of payments) {
if (p.foreign > 0 && p.foreignCode && p.foreignCode !== p.code) {
const forward = `${p.foreignCode}>${p.code}`;
const back = `${p.code}>${p.foreignCode}`;
rates.set(forward, rates.get(forward) ?? p.amount / p.foreign);
rates.set(back, rates.get(back) ?? p.foreign / p.amount);
}
}
return rates;
}
function rateFor(
from: string,
payer: Payer,
rates: Map<string, number>
): number | null {
const to = payer.card?.code ?? from;
if (from === to) {
return 1;
}
const { last } = payer;
if (last && last.code === to && last.foreignCode === from && last.foreign) {
return last.amount / last.foreign;
}
return rates.get(`${from}>${to}`) ?? null;
}
export function priceIn(payment: Payment, code: string): number | null {
if (payment.code === code) {
return payment.amount;
}
return payment.foreignCode === code && payment.foreign
? payment.foreign
: null;
}
function occurrences(bill: Bill, day: string) {
const paid = (bill.paid_dates ?? []).map((p) => (p.date ?? "").slice(0, 10));
const early = cycleDays(bill) / EARLY_SHARE;
const end = bill.end_date?.slice(0, 10);
return (bill.pay_dates ?? [])
.map((raw) => raw.slice(0, 10))
.filter((date) => !end || date <= end)
.filter(
(date) =>
date < day ||
!paid.some((p) => p <= date && daysBetween(p, date) < early)
)
.map((date) => ({ date: date < day ? day : date, overdue: date < day }));
}
export function dues(
bills: Item<Bill>[],
day: string,
payer: (bill: Item<Bill>) => Payer,
rates: Map<string, number>
): Due[] {
return bills
.filter((bill) => bill.attributes.active)
.flatMap((bill) => {
const native: Sum = {
...billCurrency(bill.attributes),
amount: average(bill.attributes),
};
const who = payer(bill);
const rate = rateFor(native.code, who, rates);
const pay: Sum =
rate !== null && who.card
? { ...who.card, amount: native.amount * rate }
: native;
return occurrences(bill.attributes, day).map(({ date, overdue }) => ({
approx: isRange(bill.attributes) || (rate !== null && rate !== 1),
bill,
card: who.card,
date,
native,
overdue,
pay,
}));
})
.sort((a, b) => a.date.localeCompare(b.date));
}
export function sum(entries: Sum[]): Sum[] {
const totals = new Map<string, Sum>();
for (const entry of entries) {
const total = totals.get(entry.code) ?? { ...entry, amount: 0 };
total.amount += entry.amount;
totals.set(entry.code, total);
}
return [...totals.values()];
}
function shortfall(list: Due[], balance: number | null, code: string) {
if (balance === null) {
return null;
}
let running = 0;
let date = "";
for (const due of list) {
if (due.pay.code === code) {
running += due.pay.amount;
if (!date && running > balance + CENT) {
({ date } = due);
}
}
}
return date ? { amount: running - balance, date } : null;
}
function rank(a: Need, b: Need): number {
const order = (n: Need) => {
if (n.short) {
return 0;
}
return n.id === UNKNOWN ? 2 : 1;
};
return order(a) - order(b) || a.name.localeCompare(b.name);
}
function need(
id: string,
name: string,
list: Due[],
currency: Currency | null,
balance: number | null
): Need {
return {
balance,
currency,
dues: list,
id,
name,
short: currency ? shortfall(list, balance, currency.code) : null,
totals: sum(list.map((d) => d.pay)),
};
}
function grouped(list: Due[], key: (due: Due) => string) {
const map = new Map<string, Due[]>();
for (const due of list) {
map.set(key(due), [...(map.get(key(due)) ?? []), due]);
}
return map;
}
export function byCard(list: Due[]): Need[] {
return [...grouped(list, (d) => d.card?.id ?? UNKNOWN)]
.map(([id, items]) => {
const { card } = items[0];
return card
? need(id, card.name, items, card, card.balance)
: need(id, "Card unknown", items, null, null);
})
.sort(rank);
}
export function byCurrency(list: Due[]): Need[] {
return [...grouped(list, (d) => d.pay.code)]
.map(([code, items]) => {
const paying = new Map<string, number>();
for (const { card } of items) {
if (card?.code === code && card.balance !== null) {
paying.set(card.id, card.balance);
}
}
const balance = paying.size
? [...paying.values()].reduce((total, b) => total + b, 0)
: null;
return need(code, code, items, items[0].pay, balance);
})
.sort(rank);
}
export interface Creep extends Currency {
from: number;
since: string;
to: number;
}
export function creep(bill: Bill, history: Payment[]): Creep | null {
if (isRange(bill)) {
return null;
}
const currency = billCurrency(bill);
const prices = history
.map((p) => ({ amount: priceIn(p, currency.code), date: p.date }))
.filter((p): p is { amount: number; date: string } => p.amount !== null);
const [latest] = prices;
const change = prices.findIndex(
(p) => Math.abs(p.amount - (latest?.amount ?? 0)) > CENT
);
if (!latest || change < 1) {
return null;
}
return {
...currency,
from: prices[change].amount,
since: prices[change - 1].date,
to: latest.amount,
};
}
export function staleSince(
bill: Bill,
last: Payment | undefined,
day: string
): string | null {
const from = last?.date ?? bill.date?.slice(0, 10);
if (!(bill.active && from) || twoCyclesAfter(bill, from) >= day) {
return null;
}
return last?.date ?? "";
}
export function matches(
bills: Item<Bill>[],
candidates: Payment[],
last: (bill: Item<Bill>) => Payment | undefined,
day: string
): Map<string, Payment[]> {
const found = new Map<string, Payment[]>();
const active = bills.filter((b) => b.attributes.active && last(b));
for (const payment of candidates.filter((c) => c.date <= day)) {
const fits = active
.map((bill) => {
const a = bill.attributes;
const price = priceIn(payment, a.currency_code ?? "");
const min = toNumber(a.amount_min);
const max = toNumber(a.amount_max);
const ok =
price !== null &&
last(bill)?.destinationId === payment.destinationId &&
price >= min - CENT &&
price <= max + CENT &&
payment.date >= (a.date ?? "").slice(0, 10);
return { bill, ok, width: max - min };
})
.filter((f) => f.ok)
.sort((x, y) => x.width - y.width);
const best = fits[0]?.bill;
if (best) {
found.set(best.id, [...(found.get(best.id) ?? []), payment]);
}
}
return found;
}
export const isYearly = (bill: Bill) => bill.repeat_freq === "yearly";
+404 -104
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import Ban from "@lucide/svelte/icons/ban";
import Pin from "@lucide/svelte/icons/pin";
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 type { Bill, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import {
byObjectGroup,
@@ -14,15 +14,11 @@
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 { Arm, 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,
@@ -30,7 +26,11 @@
strip,
yearly,
} from "#lib/components/subscriptions/bills.js";
import CardMenu from "#lib/components/subscriptions/CardMenu.svelte";
import CardPicker from "#lib/components/subscriptions/CardPicker.svelte";
import Money from "#lib/components/subscriptions/Money.svelte";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import Runway from "#lib/components/subscriptions/Runway.svelte";
import Strip from "#lib/components/subscriptions/Strip.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
@@ -43,14 +43,41 @@
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { addDays, today } from "#lib/dates.js";
import { formatDate, formatNumber, toNumber } 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 { manualCards } from "#lib/subscriptions/manual.svelte.js";
import { runwayQueries } from "#lib/subscriptions/queries.js";
import {
byBill,
byCard,
byCurrency,
creep,
dues,
HORIZONS,
type Horizon,
horizonEnd,
isYearly,
matches,
ownRates,
type Payment,
payerOf,
RENEWAL_DAYS,
STRIP_DAYS,
staleSince,
toCards,
} from "#lib/subscriptions/runway.js";
import { type Targets, updateGroups } from "#lib/transactions/mass.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const day = today();
const fetchStart = `${day.slice(0, 7)}-01`;
const fetchEnd = addDays(day, RENEWAL_DAYS);
const stripEnd = addDays(day, STRIP_DAYS);
const months = $derived(lastMonths(period.end));
const billsKey = $derived(["subscriptions", period.start, period.end]);
@@ -66,19 +93,50 @@
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 upcoming = createQuery(() => runwayQueries.bills(fetchStart, fetchEnd));
const paid = createQuery(() => runwayQueries.paid());
const unlinked = createQuery(() => runwayQueries.unlinked(day));
const assets = createQuery(() => lists.accounts("asset"));
const hideInactive = $derived(
page.url.searchParams.get("inactive") === "hide"
const params = $derived(page.url.searchParams);
const hideInactive = $derived(params.get("inactive") === "hide");
const horizon = $derived<Horizon>(
HORIZONS.find((h) => h.id === params.get("horizon"))?.id ?? "7"
);
const perCurrency = $derived(params.get("by") === "currency");
const cards = $derived(
toCards((assets.data ?? []).filter((a) => a.attributes.active !== false))
);
const cardList = $derived(
[...cards.values()].sort((a, b) => a.name.localeCompare(b.name))
);
const payments = $derived(byBill(paid.data ?? []));
const rates = $derived(ownRates(paid.data ?? []));
const lastOf = (bill: Item<Bill>): Payment | undefined =>
payments.get(bill.id)?.[0];
const payer = (bill: Item<Bill>) =>
payerOf(lastOf(bill), cards, manualCards.get(bill.id));
const allDues = $derived(dues(upcoming.data ?? [], day, payer, rates));
const within = (to: string) => allDues.filter((d) => d.date <= to);
const grouped = (to: string) =>
perCurrency ? byCurrency(within(to)) : byCard(within(to));
const end = $derived(horizonEnd(horizon, day));
const needs = $derived(grouped(end));
const stripNeeds = $derived(grouped(stripEnd));
const renewals = $derived(
within(fetchEnd).filter((d) => isYearly(d.bill.attributes))
);
const runwayStatus = $derived.by(() => {
const failed = [upcoming, paid].find((q) => q.error);
if (failed?.error) {
return failed.error.message;
}
return upcoming.isPending || paid.isPending ? "Loading" : "";
});
const visible = $derived(
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
);
@@ -92,6 +150,20 @@
const pastCells = $derived(
new Map((history.data ?? []).map((b) => [b.id, b.attributes]))
);
const found = $derived(
matches(bills.data ?? [], unlinked.data ?? [], lastOf, day)
);
const inactiveGroup = $derived.by(() => {
const counts = new Map<string, number>();
for (const { attributes: b } of bills.data ?? []) {
const title = b.object_group_title;
if (title) {
counts.set(title, (counts.get(title) ?? 0) + (b.active ? -1 : 1));
}
}
const [best] = [...counts].sort((a, b) => b[1] - a[1]);
return best && best[1] > 0 ? best[0] : null;
});
const activeOnly = (items: Item<Bill>[]) =>
items.filter((b) => b.attributes.active).map((b) => b.attributes);
@@ -100,47 +172,31 @@
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("");
let linking = $state<Record<string, string>>({});
const arm = new Arm();
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
const edit = (b: Item<Bill>) => goto(`/subscriptions/edit/${b.id}`);
function toggleInactive() {
function setParam(name: string, value: string | null) {
const url = new URL(page.url.href);
if (hideInactive) {
url.searchParams.delete("inactive");
if (value === null) {
url.searchParams.delete(name);
} else {
url.searchParams.set("inactive", "hide");
url.searchParams.set(name, value);
}
goto(url, { replace: true });
}
const toggleInactive = () =>
setParam("inactive", hideInactive ? null : "hide");
const setHorizon = (id: Horizon) =>
setParam("horizon", id === "7" ? null : id);
const toggleMode = () => setParam("by", perCurrency ? null : "currency");
async function update(b: Item<Bill>, body: Record<string, unknown>) {
failure = "";
try {
@@ -176,9 +232,92 @@
await invalidate();
}
function deactivate(b: Item<Bill> | undefined) {
if (b?.attributes.active) {
arm.press(`off:${b.id}`, () => update(b, { active: false }));
}
}
function retire(b: Item<Bill> | undefined) {
const title = inactiveGroup;
if (b && title && b.attributes.object_group_title !== title) {
arm.press(`move:${b.id}`, () =>
update(b, { active: false, object_group_title: title })
);
}
}
async function link(b: Item<Bill> | undefined) {
const list = b ? found.get(b.id) : undefined;
if (!(b && list?.length) || linking[b.id]) {
return;
}
const targets: Targets = new Map();
for (const p of list) {
const set = targets.get(p.groupId) ?? new Set<string>();
set.add(p.journalId);
targets.set(p.groupId, set);
}
const say = (text: string) => (linking = { ...linking, [b.id]: text });
const outcome = await updateGroups(
targets,
() => ({ bill_id: b.id }),
(o) => say(`Linking ${o.done} of ${o.total}`)
);
await invalidate();
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
}
const pick = (b: Item<Bill> | undefined) => {
if (b?.attributes.active) {
manualCards.picking = b.id;
}
};
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
edit,
extra: () => [
...HORIZONS.map((h) => ({
group: "Runway",
keys: h.key,
label:
h.id === "month" ? "Needed by end of month" : `Needed in ${h.label}`,
run: () => setHorizon(h.id),
})),
{
group: "Runway",
keys: "p",
label: perCurrency ? "Group by card" : "Group by currency",
run: toggleMode,
},
{
group: "Subscriptions",
keys: "a",
label: "Set paying card",
run: () => pick(nav.current),
},
{
group: "Subscriptions",
keys: "L",
label: "Link payments found for this subscription",
run: () => link(nav.current),
},
{
group: "Subscriptions",
keys: "D",
label: "Deactivate (press twice)",
run: () => deactivate(nav.current),
},
...(inactiveGroup
? [
{
group: "Subscriptions",
keys: "M",
label: `Move to ${inactiveGroup} (press twice)`,
run: () => retire(nav.current),
},
]
: []),
{
group: "Subscriptions",
keys: "J",
@@ -211,8 +350,18 @@
});
nav.use();
const head = "px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
function rowTone(cursor: boolean, stale: boolean) {
if (cursor) {
return "bg-row-cursor";
}
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
}
const head =
"whitespace-nowrap px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
const cell = "px-3 py-1 align-middle";
const action =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-6 items-center gap-1.5 rounded-cladd-sm px-2 font-semibold text-[12px]";
</script>
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
@@ -223,23 +372,27 @@
{/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>
<Runway
byCurrency={perCurrency}
cards={cardList}
{day}
{end}
{horizon}
{needs}
onhorizon={setHorizon}
onmode={toggleMode}
{renewals}
status={runwayStatus}
strip={stripNeeds}
/>
{#snippet sums(
totals: Total[]
)}
{#each totals as total (total.code)}
<div><Money {...total} /></div>
{/each}
</div>
{/snippet}
<Box body="p-0" title="Subscriptions">
{#snippet tools()}
@@ -274,10 +427,12 @@
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Name</th>
<th class="{head} text-right">Amount</th>
<th class="{head} hidden lg:table-cell">Price change</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">
<th class="{head} hidden md:table-cell">Card</th>
<th class="{head} hidden 2xl:table-cell">Repeats</th>
<th class="{head} hidden 2xl:table-cell">
<span class="inline-flex gap-[3px]">
{#each months as month (month)}
<span class="w-2.5 text-center text-[10px]"
@@ -295,38 +450,46 @@
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60"
>
<td class="{cell} font-semibold">
<td
class="{cell} whitespace-nowrap font-semibold text-cladd-fg"
>
{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 class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(monthTotals)}
</td>
<td class="hidden lg:table-cell"></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="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
>
<Totals tone="expense" totals={perYear(section.items)} />
{@render sums(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)}
{@const last = lastOf(item)}
{@const who = payer(item)}
{@const change = creep(bill, payments.get(item.id) ?? [])}
{@const stale = staleSince(bill, last, day)}
{@const extra = found.get(item.id) ?? []}
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
<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"}"
class="h-7 border-cladd-outline/60 border-t {rowTone(
nav.cursor === i,
stale !== null
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
@@ -334,9 +497,9 @@
nav.cursor = i;
}}
>
<td class={cell}>
<td class="{cell} md:whitespace-nowrap">
<a
class={bill.active ? LINK : "hover:underline"}
class="hover:underline"
href="/subscriptions/show/{item.id}"
>
{#if !bill.active}
@@ -348,41 +511,90 @@
{bill.name}
</a>
</td>
<td class="{cell} whitespace-nowrap text-right">
<Amount
amount={bill.amount_min}
code={bill.currency_code}
<td class="{cell} whitespace-nowrap text-right font-medium">
{#if toNumber(bill.amount_max) !==
toNumber(bill.amount_min)}
<span
class="num me-1.5 font-normal text-[12px] text-cladd-fg-softer max-md:hidden"
>{toNumber(bill.amount_min)}–{toNumber(
bill.amount_max
)}</span
>
{/if}
<Money
amount={(toNumber(bill.amount_min) +
toNumber(bill.amount_max)) /
2}
approx={toNumber(bill.amount_max) !==
toNumber(bill.amount_min)}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone={bill.active ? "expense" : "neutral"}
symbol={bill.currency_symbol ?? ""}
/>
{#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"}
/>
</td>
<td class="{cell} hidden whitespace-nowrap lg:table-cell">
{#if change}
<span
class="num {change.to > change.from
? "text-expense"
: "text-income"}"
>{formatNumber(change.from, change.decimals)}
→ {formatNumber(change.to, change.decimals)}
<span class="text-cladd-fg-soft"
>since {formatDate(change.since, "short")}</span
></span
>
{/if}
</td>
<td class={cell}>
<PaidStatus state={status(bill, period.range)} />
<td class="{cell} md:whitespace-nowrap">
{#if stale !== null}
<span class="text-warn"
>{#if stale}
Unpaid since
<span class="whitespace-nowrap"
>{formatDate(stale, "short")}</span
>
{:else}
Never paid
{/if}</span
>
{:else}
<PaidStatus state={status(bill, period.range)} />
{/if}
</td>
<td class="{cell} num hidden md:table-cell">
{bill.active ? formatDate(nextExpected(bill)) : ""}
</td>
<td class="{cell} hidden whitespace-nowrap md:table-cell">
{#if manualCards.picking === item.id}
<CardPicker billId={item.id} cards={cardList} />
{:else if bill.active}
{#if who.card}
<span class="text-cladd-fg-soft"
>{who.card.name}</span
>
{#if who.manual}
<Pin
aria-label="Set by hand"
class="ms-0.5 inline size-3 align-[-1px] text-cladd-fg-softer"
/>
{/if}
{:else}
<span class="text-warn">card unknown</span>
{/if}
{/if}
</td>
<td class="{cell} hidden whitespace-nowrap 2xl:table-cell">
{repeats(bill)}
{#if ends}
<span
class={ends < today()
class={ends < day
? "text-expense"
: "text-cladd-fg-soft"}
>, ends {formatDate(ends)}</span
>
{/if}
</td>
<td class="{cell} hidden xl:table-cell">
<td class="{cell} hidden 2xl:table-cell">
<Strip
cells={strip(pastCells.get(item.id) ?? bill, months)}
{months}
@@ -390,30 +602,108 @@
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if bill.active}
<Amount {...yearly(bill)} tone="expense" />
<Money {...yearly(bill)} />
{/if}
</td>
</tr>
{#if extra.length > 0 || stale !== null || armedHere}
<tr class="border-0">
<td class="px-3 pb-1.5 text-[12px]" colspan="9">
<div
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
>
{#if extra.length > 0}
<span class="text-warn">Not linked:</span>
{#each extra as p (p.journalId)}
<a
class="text-cladd-fg hover:underline"
href="/transactions/show/{p.groupId}"
><span class="num text-cladd-fg-soft"
>{formatDate(p.date, "short")}</span
>
{p.description}
<Money {...p} class="font-medium" /></a
>
{/each}
<button
class="{action} text-cladd-fg"
disabled={Boolean(linking[item.id])}
onclick={() => link(item)}
type="button"
>
Link
{extra.length === 1
? "it"
: `all ${extra.length}`}<span
class="kbd max-sm:hidden"
>L</span
>
</button>
{#if linking[item.id]}
<span
aria-live="polite"
class="text-cladd-fg-soft"
>{linking[item.id]}</span
>
{/if}
{/if}
{#if stale !== null || armedHere}
<button
class="{action} {arm.key === `off:${item.id}`
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"}"
onclick={() => deactivate(item)}
type="button"
>
{arm.key === `off:${item.id}`
? "Press D again to deactivate"
: "Deactivate"}<span class="kbd max-sm:hidden"
>D</span
>
</button>
{#if inactiveGroup}
<button
class="{action} {arm.key === `move:${item.id}`
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"}"
onclick={() => retire(item)}
type="button"
>
{arm.key === `move:${item.id}`
? `Press M again to move to ${inactiveGroup}`
: `Move to ${inactiveGroup}`}<span
class="kbd max-sm:hidden"
>M</span
>
</button>
{/if}
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
{/each}
{#if sections.length > 1}
<tfoot>
<tr class="border-cladd-outline border-t-2">
<td class="{cell} font-semibold">
<td class="{cell} font-semibold text-cladd-fg">
Total, active subscriptions
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={perMonth(rows)} />
<td class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(perMonth(rows))}
</td>
<td class="hidden lg:table-cell"></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="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
>
<Totals tone="expense" totals={perYear(rows)} />
{@render sums(perYear(rows))}
</td>
</tr>
</tfoot>
@@ -426,6 +716,9 @@
{@const target = menuFor}
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
{#if target.attributes.active}
<CardMenu billId={target.id} cards={cardList} />
{/if}
<ContextMenuSub>
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
<ContextMenuSubContent class="max-h-80 w-52">
@@ -447,6 +740,13 @@
>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() =>
update(target, { active: !target.attributes.active })}
>{target.attributes.active
? "Deactivate"
: "Activate"}</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>