Files
firefly-ui/src/lib/components/subscriptions/Runway.svelte
T
hh 4a03ab8e1a feat(period): p and a letter picks a period from any page
p t current period, p m month to date, p 7 and p 3 the last seven and
thirty days, p y year to date, p l last year, p a everything, p c
custom dates, p p opens the period menu, which now shows each key. The
same presets are in the palette. Currencies make primary moves to P and
the subscriptions card/currency toggle to v to free p.
2026-10-17 16:13:29 +00:00

265 lines
8.3 KiB
Svelte

<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";
import Sums from "./Sums.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;
}
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) =>
buttonVariants({ size: "sm", 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">v</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 class="outline-none" {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} exact />
</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="py-0.5" data-bill={due.bill.id}>
<div class="flex h-5 items-center gap-2">
<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"
exact
/>
</div>
<div
class="truncate ps-14 text-[11px] text-cladd-fg-softer leading-4"
>
{chosen(due)}
</div>
</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 billIds={[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}, <Sums inline parts={renewalTotals} /></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>