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:
1 parent
153a8cb191
commit
68f64893f7
10 files changed
+1425
-105
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user