feat(subscriptions): paying card set for several at once and kept in Firefly, quieter table

Each runway line says whether its card came from the last payment or
was set by hand; x selects bills and a sets the card for all of them,
stored in the Firefly preference ffui_bill_cards so every device sees
it. Price change moves to the subscription page, per year turns grey,
group sums follow the one-currency mode, key chips fit their buttons and
the panels no longer show a focus ring. Linking found payments is y.
This commit is contained in:
hh committed 2026-10-16 18:22:01 +00:00
1 parent 54895b9305
commit c610aa58fa
10 files changed
+285 -130

No files matched your search

@@ -128,7 +128,7 @@
: "text-cladd-fg"}" : "text-cladd-fg"}"
> >
{#each row.need.totals as total, i (total.code)} {#each row.need.totals as total, i (total.code)}
{i > 0 ? "+" : ""}<Money {...total} /> {i > 0 ? "+" : ""}<Money {...total} exact />
{/each} {/each}
</div> </div>
{/each} {/each}
@@ -10,27 +10,32 @@
import { manualCards } from "#lib/subscriptions/manual.svelte.js"; import { manualCards } from "#lib/subscriptions/manual.svelte.js";
import type { Card } from "#lib/subscriptions/runway.js"; import type { Card } from "#lib/subscriptions/runway.js";
let { billId, cards }: { billId: string; cards: Card[] } = $props(); let { billIds, cards }: { billIds: string[]; cards: Card[] } = $props();
const chosen = $derived(manualCards.get(billId)); const chosen = $derived(billIds.map((id) => manualCards.get(id)));
const shared = $derived(chosen.every((id) => id === chosen[0]) && chosen[0]);
</script> </script>
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger>Paying card</ContextMenuSubTrigger> <ContextMenuSubTrigger
>{billIds.length > 1
? `Paying card for ${billIds.length}`
: "Paying card"}</ContextMenuSubTrigger
>
<ContextMenuSubContent class="max-h-80 w-60"> <ContextMenuSubContent class="max-h-80 w-60">
{#each cards as card (card.id)} {#each cards as card (card.id)}
<ContextMenuItem onSelect={() => manualCards.set(billId, card.id)}> <ContextMenuItem onSelect={() => manualCards.set(billIds, card.id)}>
<span class="flex-1 truncate">{card.name}</span> <span class="flex-1 truncate">{card.name}</span>
<span class="text-[11px] text-cladd-fg-softer">{card.code}</span> <span class="text-[11px] text-cladd-fg-softer">{card.code}</span>
{#if chosen === card.id} {#if shared === card.id}
<Check class="size-3.5" /> <Check class="size-3.5" />
{/if} {/if}
</ContextMenuItem> </ContextMenuItem>
{/each} {/each}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
disabled={!chosen} disabled={!chosen.some(Boolean)}
onSelect={() => manualCards.set(billId, null)} onSelect={() => manualCards.set(billIds, null)}
>Card of the last payment</ContextMenuItem >Card of the last payment</ContextMenuItem
> >
</ContextMenuSubContent> </ContextMenuSubContent>
@@ -7,7 +7,7 @@
const AUTO = "\u0000"; const AUTO = "\u0000";
let { billId, cards }: { billId: string; cards: Card[] } = $props(); let { billIds, cards }: { billIds: string[]; cards: Card[] } = $props();
let text = $state(""); let text = $state("");
let input = $state<HTMLInputElement>(); let input = $state<HTMLInputElement>();
@@ -31,21 +31,23 @@
input?.focus(); input?.focus();
}); });
const cancel = () => (manualCards.picking = ""); const cancel = () => (manualCards.picking = null);
</script> </script>
<SuggestInput <SuggestInput
aria-label="Paying card" aria-label="Paying card"
class="w-56" class="w-56"
id="bill-card-{billId}" id="bill-card-{billIds[0]}"
onaccept={(item) => { onaccept={(item) => {
manualCards.set(billId, item.insert === AUTO ? null : item.insert); manualCards.set(billIds, item.insert === AUTO ? null : item.insert);
return true; return true;
}} }}
onblur={cancel} onblur={cancel}
oncancel={cancel} oncancel={cancel}
onsubmit={cancel} onsubmit={cancel}
placeholder="Paying card" placeholder={billIds.length > 1
? `Paying card for ${billIds.length}`
: "Paying card"}
{suggestions} {suggestions}
bind:input bind:input
bind:value={text} bind:value={text}
+32 -3
View File
@@ -1,23 +1,52 @@
<script lang="ts"> <script lang="ts">
import { formatNumber, toNumber } from "#lib/format.js"; import { formatNumber, toNumber } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
let { let {
amount, amount,
code,
symbol, symbol,
decimals, decimals,
approx = false, approx = false,
converted = false,
exact = false,
class: className = "", class: className = "",
}: { }: {
amount: number | string | null | undefined; amount: number | string | null | undefined;
code?: string | null;
symbol?: string | null; symbol?: string | null;
decimals?: number | null; decimals?: number | null;
approx?: boolean; approx?: boolean;
converted?: boolean;
exact?: boolean;
class?: string; class?: string;
} = $props(); } = $props();
const value = $derived(Math.abs(toNumber(amount)));
const into = $derived(
exact || !code || code === display.code
? null
: display.convert(value, code)
);
const shown = $derived(
into === null
? { amount: value, decimals, symbol }
: { ...display.target, amount: into }
);
const mark = $derived.by(() => {
if (converted || into !== null) {
return "≈";
}
return approx ? "~" : "";
});
</script> </script>
<span class="num {className}" <span
class="num {className}"
title={into === null
? undefined
: `${symbol ?? ""}${formatNumber(value, decimals ?? 2)}`}
><span class="font-normal text-cladd-fg-softer" ><span class="font-normal text-cladd-fg-softer"
>{approx ? "~" : ""}{symbol ?? ""}</span >{mark}{shown.symbol ?? ""}</span
>{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)}</span >{formatNumber(shown.amount, shown.decimals ?? 2)}</span
> >
+44 -29
View File
@@ -24,6 +24,7 @@
import Calendar from "./Calendar.svelte"; import Calendar from "./Calendar.svelte";
import CardMenu from "./CardMenu.svelte"; import CardMenu from "./CardMenu.svelte";
import Money from "./Money.svelte"; import Money from "./Money.svelte";
import Sums from "./Sums.svelte";
let { let {
needs, needs,
@@ -69,8 +70,20 @@
needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null; needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null;
} }
function chosen(due: Due): string {
const card = byCurrency && due.card ? `${due.card.name}: ` : "";
if (due.manual) {
return `${card}set by you`;
}
if (!due.lastPaid) {
return "never paid";
}
const where = byCurrency ? "" : " here";
return `${card}last paid${where} ${formatDate(due.lastPaid, "short")}`;
}
const segment = (on: boolean) => const segment = (on: boolean) =>
buttonVariants({ size: "xs", variant: on ? "default" : "outline" }); buttonVariants({ size: "sm", variant: on ? "default" : "outline" });
</script> </script>
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg"> <section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
@@ -122,7 +135,7 @@
</p> </p>
{:else} {:else}
<ContextMenu> <ContextMenu>
<ContextMenuTrigger {oncontextmenu}> <ContextMenuTrigger class="outline-none" {oncontextmenu}>
<div <div
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]" class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
> >
@@ -143,7 +156,7 @@
<div <div
class="font-semibold text-[26px] text-cladd-fg leading-tight" class="font-semibold text-[26px] text-cladd-fg leading-tight"
> >
<Money {...total} /> <Money {...total} exact />
</div> </div>
{/each} {/each}
{#if need.short && need.currency} {#if need.short && need.currency}
@@ -170,28 +183,33 @@
{/if} {/if}
<ul class="mt-2 border-cladd-outline border-t pt-1 text-[12px]"> <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}`)} {#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)}
<li <li class="py-0.5" data-bill={due.bill.id}>
class="flex h-6 items-center gap-2" <div class="flex h-5 items-center gap-2">
data-bill={due.bill.id} <span
> class="num w-12 shrink-0 {due.overdue
<span ? "text-expense"
class="num w-12 shrink-0 {due.overdue : "text-cladd-fg-soft"}"
? "text-expense" >{due.overdue
: "text-cladd-fg-soft"}" ? "overdue"
>{due.overdue : formatDate(due.date, "short")}</span
? "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"
exact
/>
</div>
<div
class="truncate ps-14 text-[11px] text-cladd-fg-softer leading-4"
> >
<a {chosen(due)}
class="min-w-0 flex-1 truncate text-cladd-fg hover:underline" </div>
href="/subscriptions/show/{due.bill.id}"
>{due.bill.attributes.name}</a
>
<Money
{...due.pay}
approx={due.approx}
class="font-medium text-cladd-fg"
/>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -211,7 +229,7 @@
>Edit</ContextMenuItem >Edit</ContextMenuItem
> >
<ContextMenuSeparator /> <ContextMenuSeparator />
<CardMenu billId={target.id} {cards} /> <CardMenu billIds={[target.id]} {cards} />
{/if} {/if}
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
@@ -225,10 +243,7 @@
>Annual renewals in {RENEWAL_DAYS} days:</span >Annual renewals in {RENEWAL_DAYS} days:</span
> >
<span class="font-semibold text-cladd-fg" <span class="font-semibold text-cladd-fg"
>{renewals.length}, >{renewals.length}, <Sums inline parts={renewalTotals} /></span
{#each renewalTotals as total, i (total.code)}
{i > 0 ? " + " : ""}<Money {...total} />
{/each}</span
> >
{#each renewals as due (due.bill.id)} {#each renewals as due (due.bill.id)}
<span class="text-cladd-fg-soft" <span class="text-cladd-fg-soft"
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Money as Part } from "#lib/format.js";
import { display } from "#lib/money/display.svelte.js";
import Money from "./Money.svelte";
let { parts, inline = false }: { parts: Part[]; inline?: boolean } = $props();
const total = $derived(display.total(parts));
</script>
{#if total}
<Money {...total} converted={total.converted} />
{:else if inline}
{#each parts as part, i (part.code ?? i)}
{i > 0 ? " + " : ""}<Money {...part} />
{/each}
{:else}
{#each parts as part, i (part.code ?? i)}
<div><Money {...part} /></div>
{/each}
{/if}
+65 -25
View File
@@ -1,39 +1,79 @@
const PREFIX = "ffui.bill-card."; import { errorText } from "#lib/components/misc/action.svelte.js";
import { session } from "#lib/session.svelte.js";
function read(): Record<string, string> { const PREF = "ffui_bill_cards";
const ids: Record<string, string> = {}; const LEGACY = "ffui.bill-card.";
if (typeof localStorage === "undefined") {
return ids; type Choices = Record<string, string>;
}
for (let i = 0; i < localStorage.length; i += 1) { function stored(): Choices {
const key = localStorage.key(i); const data = session.prefs[PREF];
if (key?.startsWith(PREFIX)) { return data && typeof data === "object" && !Array.isArray(data)
ids[key.slice(PREFIX.length)] = localStorage.getItem(key) ?? ""; ? (data as Choices)
} : {};
} }
return ids;
function legacyKeys(): string[] {
return Array.from(
{ length: localStorage.length },
(_, i) => localStorage.key(i) ?? ""
).filter((key) => key.startsWith(LEGACY));
} }
class ManualCards { class ManualCards {
ids = $state<Record<string, string>>(read()); pending = $state.raw<Choices | null>(null);
picking = $state(""); picking = $state<{ at: string; ids: string[] } | null>(null);
failure = $state("");
ids = $derived(this.pending ?? stored());
get(billId: string): string | null { get(billId: string): string | null {
return this.ids[billId] || null; return this.ids[billId] || null;
} }
set(billId: string, cardId: string | null) { async set(billIds: string[], cardId: string | null) {
if (cardId) { const changes = Object.fromEntries(billIds.map((id) => [id, cardId ?? ""]));
localStorage.setItem(PREFIX + billId, cardId); this.picking = null;
} else { await this.save({ ...this.ids, ...changes });
localStorage.removeItem(PREFIX + billId); }
async migrate() {
const keys = legacyKeys();
if (keys.length === 0) {
return;
} }
this.ids = Object.fromEntries( const old = Object.fromEntries(
Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter( keys.map((key) => [
([, id]) => id key.slice(LEGACY.length),
) localStorage.getItem(key) ?? "",
])
); );
this.picking = ""; if (await this.save({ ...old, ...this.ids })) {
for (const key of keys) {
localStorage.removeItem(key);
}
}
}
private async save(choices: Choices): Promise<boolean> {
const next = Object.fromEntries(
Object.entries(choices).filter(([, id]) => id)
);
this.pending = next;
this.failure = "";
try {
await session.setPreference(
PREF,
Object.keys(next).length > 0 ? next : false
);
return true;
} catch (e) {
this.failure = errorText(e);
return false;
} finally {
if (this.pending === next) {
this.pending = null;
}
}
} }
} }
+4
View File
@@ -52,6 +52,8 @@ export interface Due {
bill: Item<Bill>; bill: Item<Bill>;
card: Card | null; card: Card | null;
date: string; date: string;
lastPaid: string;
manual: boolean;
native: Sum; native: Sum;
overdue: boolean; overdue: boolean;
pay: Sum; pay: Sum;
@@ -238,6 +240,8 @@ export function dues(
bill, bill,
card: who.card, card: who.card,
date, date,
lastPaid: who.last?.date ?? "",
manual: who.manual,
native, native,
overdue, overdue,
pay, pay,
+64 -59
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import Ban from "@lucide/svelte/icons/ban"; import Ban from "@lucide/svelte/icons/ban";
import Pin from "@lucide/svelte/icons/pin";
import Plus from "@lucide/svelte/icons/plus"; import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { onMount } from "svelte";
import { api } from "#lib/api/client.js"; import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js"; import { all, invalidate, lists } from "#lib/api/lists.js";
import type { Bill, Item } from "#lib/api/types.js"; import type { Bill, Item } from "#lib/api/types.js";
@@ -12,7 +12,6 @@
LINK, LINK,
sumByCurrency, sumByCurrency,
swapOrder, swapOrder,
type Total,
} from "#lib/components/groups/object-groups.js"; } from "#lib/components/groups/object-groups.js";
import { Arm, 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 PageHeader from "#lib/components/shell/PageHeader.svelte";
@@ -32,6 +31,7 @@
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte"; import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import Runway from "#lib/components/subscriptions/Runway.svelte"; import Runway from "#lib/components/subscriptions/Runway.svelte";
import Strip from "#lib/components/subscriptions/Strip.svelte"; import Strip from "#lib/components/subscriptions/Strip.svelte";
import Sums from "#lib/components/subscriptions/Sums.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte"; import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import { import {
ContextMenu, ContextMenu,
@@ -44,7 +44,8 @@
ContextMenuTrigger, ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js"; } from "#lib/components/ui/context-menu/index.js";
import { addDays, today } from "#lib/dates.js"; import { addDays, today } from "#lib/dates.js";
import { formatDate, formatNumber, toNumber } from "#lib/format.js"; import { formatDate, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js"; import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js"; import { oldUiUrl } from "#lib/spa-paths.js";
@@ -54,7 +55,6 @@
byBill, byBill,
byCard, byCard,
byCurrency, byCurrency,
creep,
dues, dues,
HORIZONS, HORIZONS,
type Horizon, type Horizon,
@@ -268,11 +268,21 @@
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : ""); say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
} }
const pick = (b: Item<Bill> | undefined) => { const activeIds = (items: Item<Bill>[]) =>
if (b?.attributes.active) { items.filter((b) => b.attributes.active).map((b) => b.id);
manualCards.picking = b.id;
function pick(targets: Item<Bill>[]) {
const ids = activeIds(targets);
const here = nav.current?.id ?? "";
const at = ids.includes(here) ? here : ids[0];
if (at) {
manualCards.picking = { at, ids };
} }
}; }
onMount(() => {
manualCards.migrate();
});
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({ const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
edit, edit,
@@ -293,12 +303,12 @@
{ {
group: "Subscriptions", group: "Subscriptions",
keys: "a", keys: "a",
label: "Set paying card", label: "Set paying card (row or selection)",
run: () => pick(nav.current), run: () => pick(nav.targets),
}, },
{ {
group: "Subscriptions", group: "Subscriptions",
keys: "L", keys: "y",
label: "Link payments found for this subscription", label: "Link payments found for this subscription",
run: () => link(nav.current), run: () => link(nav.current),
}, },
@@ -347,13 +357,21 @@
items: () => rows, items: () => rows,
key: billKey, key: billKey,
open: show, open: show,
selectable: true,
}); });
nav.use(); useKeys(() =>
nav
.bindings()
.filter((b) => b.group !== "Selection" || !["J", "K"].includes(b.keys))
);
function rowTone(cursor: boolean, stale: boolean) { function rowTone(cursor: boolean, selected: boolean, stale: boolean) {
if (cursor) { if (cursor) {
return "bg-row-cursor"; return "bg-row-cursor";
} }
if (selected) {
return "bg-row-selected";
}
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover"; return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
} }
@@ -386,22 +404,16 @@
strip={stripNeeds} strip={stripNeeds}
/> />
{#snippet sums(
totals: Total[]
)}
{#each totals as total (total.code)}
<div><Money {...total} /></div>
{/each}
{/snippet}
<Box body="p-0" title="Subscriptions"> <Box body="p-0" title="Subscriptions">
{#snippet tools()} {#snippet tools()}
{#if failure} {#if failure || manualCards.failure}
<span class="text-[12px] text-expense">{failure}</span> <span class="text-[12px] text-expense"
>{failure || manualCards.failure}</span
>
{/if} {/if}
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span> <span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
<button <button
class={buttonVariants({ size: "xs", variant: "outline" })} class={buttonVariants({ size: "sm", variant: "outline" })}
onclick={toggleInactive} onclick={toggleInactive}
type="button" type="button"
> >
@@ -420,14 +432,13 @@
</p> </p>
{:else} {:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}> <ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger> <ContextMenuTrigger class="block outline-none">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-[13px]"> <table class="w-full text-[13px]">
<thead> <thead>
<tr class="border-cladd-outline border-b text-left"> <tr class="border-cladd-outline border-b text-left">
<th class={head}>Name</th> <th class={head}>Name</th>
<th class="{head} text-right">Amount</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}>Paid this period</th>
<th class="{head} hidden md:table-cell">Next expected match</th> <th class="{head} hidden md:table-cell">Next expected match</th>
<th class="{head} hidden md:table-cell">Card</th> <th class="{head} hidden md:table-cell">Card</th>
@@ -459,9 +470,8 @@
> >
</td> </td>
<td class="{cell} text-right font-semibold text-cladd-fg"> <td class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(monthTotals)} <Sums parts={monthTotals} />
</td> </td>
<td class="hidden lg:table-cell"></td>
<td class="{cell} text-[12px] text-cladd-fg-soft"> <td class="{cell} text-[12px] text-cladd-fg-soft">
{monthTotals.length > 0 ? "a month" : ""} {monthTotals.length > 0 ? "a month" : ""}
</td> </td>
@@ -470,9 +480,9 @@
<td class="hidden 2xl:table-cell"></td> <td class="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td> <td class="hidden 2xl:table-cell"></td>
<td <td
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell" class="{cell} hidden text-right font-semibold text-cladd-fg-soft lg:table-cell"
> >
{@render sums(perYear(section.items))} <Sums parts={perYear(section.items)} />
</td> </td>
</tr> </tr>
{#each section.items as item, j (item.id)} {#each section.items as item, j (item.id)}
@@ -481,13 +491,13 @@
{@const ends = bill.end_date?.slice(0, 10)} {@const ends = bill.end_date?.slice(0, 10)}
{@const last = lastOf(item)} {@const last = lastOf(item)}
{@const who = payer(item)} {@const who = payer(item)}
{@const change = creep(bill, payments.get(item.id) ?? [])}
{@const stale = staleSince(bill, last, day)} {@const stale = staleSince(bill, last, day)}
{@const extra = found.get(item.id) ?? []} {@const extra = found.get(item.id) ?? []}
{@const armedHere = arm.key?.endsWith(`:${item.id}`)} {@const armedHere = arm.key?.endsWith(`:${item.id}`)}
<tr <tr
class="h-7 border-cladd-outline/60 border-t {rowTone( class="h-7 border-cladd-outline/60 border-t {rowTone(
nav.cursor === i, nav.cursor === i,
nav.isSelected(item),
stale !== null stale !== null
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}" )} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
id={nav.rowId(i)} id={nav.rowId(i)}
@@ -527,24 +537,11 @@
2} 2}
approx={toNumber(bill.amount_max) !== approx={toNumber(bill.amount_max) !==
toNumber(bill.amount_min)} toNumber(bill.amount_min)}
code={bill.currency_code}
decimals={bill.currency_decimal_places} decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol ?? ""} symbol={bill.currency_symbol ?? ""}
/> />
</td> </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} md:whitespace-nowrap"> <td class="{cell} md:whitespace-nowrap">
{#if stale !== null} {#if stale !== null}
<span class="text-warn" <span class="text-warn"
@@ -565,18 +562,20 @@
{bill.active ? formatDate(nextExpected(bill)) : ""} {bill.active ? formatDate(nextExpected(bill)) : ""}
</td> </td>
<td class="{cell} hidden whitespace-nowrap md:table-cell"> <td class="{cell} hidden whitespace-nowrap md:table-cell">
{#if manualCards.picking === item.id} {#if manualCards.picking?.at === item.id}
<CardPicker billId={item.id} cards={cardList} /> <CardPicker
billIds={manualCards.picking.ids}
cards={cardList}
/>
{:else if bill.active} {:else if bill.active}
{#if who.card} {#if who.card}
<span class="text-cladd-fg-soft" <span class="text-cladd-fg-soft"
>{who.card.name}</span >{who.card.name}</span
> >
{#if who.manual} {#if who.manual}
<Pin <span class="ms-1 text-[12px] text-cladd-fg-softer"
aria-label="Set by hand" >set by you</span
class="ms-0.5 inline size-3 align-[-1px] text-cladd-fg-softer" >
/>
{/if} {/if}
{:else} {:else}
<span class="text-warn">card unknown</span> <span class="text-warn">card unknown</span>
@@ -600,7 +599,9 @@
{months} {months}
/> />
</td> </td>
<td class="{cell} hidden text-right lg:table-cell"> <td
class="{cell} hidden text-right text-cladd-fg-soft lg:table-cell"
>
{#if bill.active} {#if bill.active}
<Money {...yearly(bill)} /> <Money {...yearly(bill)} />
{/if} {/if}
@@ -608,7 +609,7 @@
</tr> </tr>
{#if extra.length > 0 || stale !== null || armedHere} {#if extra.length > 0 || stale !== null || armedHere}
<tr class="border-0"> <tr class="border-0">
<td class="px-3 pb-1.5 text-[12px]" colspan="9"> <td class="px-3 pb-1.5 text-[12px]" colspan="8">
<div <div
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4" class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
> >
@@ -636,7 +637,7 @@
? "it" ? "it"
: `all ${extra.length}`}<span : `all ${extra.length}`}<span
class="kbd max-sm:hidden" class="kbd max-sm:hidden"
>L</span >y</span
> >
</button> </button>
{#if linking[item.id]} {#if linking[item.id]}
@@ -692,18 +693,17 @@
Total, active subscriptions Total, active subscriptions
</td> </td>
<td class="{cell} text-right font-semibold text-cladd-fg"> <td class="{cell} text-right font-semibold text-cladd-fg">
{@render sums(perMonth(rows))} <Sums parts={perMonth(rows)} />
</td> </td>
<td class="hidden lg:table-cell"></td>
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</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 md:table-cell"></td> <td class="hidden md:table-cell"></td>
<td class="hidden 2xl:table-cell"></td> <td class="hidden 2xl:table-cell"></td>
<td class="hidden 2xl:table-cell"></td> <td class="hidden 2xl:table-cell"></td>
<td <td
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell" class="{cell} hidden text-right font-semibold text-cladd-fg-soft lg:table-cell"
> >
{@render sums(perYear(rows))} <Sums parts={perYear(rows)} />
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -717,7 +717,12 @@
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem> <ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem> <ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
{#if target.attributes.active} {#if target.attributes.active}
<CardMenu billId={target.id} cards={cardList} /> <CardMenu
billIds={nav.isSelected(target)
? activeIds(nav.selection)
: [target.id]}
cards={cardList}
/>
{/if} {/if}
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger> <ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
@@ -30,10 +30,16 @@
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte"; import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte"; import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import { addMonths } from "#lib/dates.js"; import { addMonths } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js"; import {
formatDate,
formatMoney,
formatNumber,
toNumber,
} from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js"; import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js"; import { oldUiUrl } from "#lib/spa-paths.js";
import { creep, toPayments } from "#lib/subscriptions/runway.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -82,6 +88,14 @@
); );
type Payment = (typeof payments)[number]; type Payment = (typeof payments)[number];
const change = $derived(
bill &&
creep(
bill,
toPayments(transactions.data ?? []).filter((p) => p.billId === id)
)
);
function averages(list: Payment[]): Total[] { function averages(list: Payment[]): Total[] {
return sumByCurrency(list).map((t) => ({ return sumByCurrency(list).map((t) => ({
...t, ...t,
@@ -188,6 +202,7 @@
<Amount <Amount
amount={bill.amount_min} amount={bill.amount_min}
decimals={bill.currency_decimal_places} decimals={bill.currency_decimal_places}
exact
symbol={bill.currency_symbol} symbol={bill.currency_symbol}
tone="expense" tone="expense"
/> />
@@ -195,6 +210,7 @@
<Amount <Amount
amount={bill.amount_max} amount={bill.amount_max}
decimals={bill.currency_decimal_places} decimals={bill.currency_decimal_places}
exact
symbol={bill.currency_symbol} symbol={bill.currency_symbol}
tone="expense" tone="expense"
/>. Repeats {repeats(bill)}. />. Repeats {repeats(bill)}.
@@ -216,6 +232,24 @@
{formatDate(nextExpected(bill)) || "unknown"} {formatDate(nextExpected(bill)) || "unknown"}
</td> </td>
</tr> </tr>
{#if change}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Price change</td>
<td class={value}>
<span
class="num {change.to > change.from
? "text-expense"
: "text-income"}"
>{formatNumber(change.from, change.decimals)}
→ {formatNumber(change.to, change.decimals)}
{change.code}</span
>
<span class="text-cladd-fg-soft"
>since {formatDate(change.since, "short")}</span
>
</td>
</tr>
{/if}
<tr class="border-cladd-outline/60 border-b"> <tr class="border-cladd-outline/60 border-b">
<td class={label}>Group</td> <td class={label}>Group</td>
<td class={value}> <td class={value}>