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:
1 parent
54895b9305
commit
c610aa58fa
10 files changed
+285
-130
No files matched your search
@@ -128,7 +128,7 @@
|
||||
: "text-cladd-fg"}"
|
||||
>
|
||||
{#each row.need.totals as total, i (total.code)}
|
||||
{i > 0 ? "+" : ""}<Money {...total} />
|
||||
{i > 0 ? "+" : ""}<Money {...total} exact />
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -10,27 +10,32 @@
|
||||
import { manualCards } from "#lib/subscriptions/manual.svelte.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>
|
||||
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Paying card</ContextMenuSubTrigger>
|
||||
<ContextMenuSubTrigger
|
||||
>{billIds.length > 1
|
||||
? `Paying card for ${billIds.length}`
|
||||
: "Paying card"}</ContextMenuSubTrigger
|
||||
>
|
||||
<ContextMenuSubContent class="max-h-80 w-60">
|
||||
{#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="text-[11px] text-cladd-fg-softer">{card.code}</span>
|
||||
{#if chosen === card.id}
|
||||
{#if shared === card.id}
|
||||
<Check class="size-3.5" />
|
||||
{/if}
|
||||
</ContextMenuItem>
|
||||
{/each}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={!chosen}
|
||||
onSelect={() => manualCards.set(billId, null)}
|
||||
disabled={!chosen.some(Boolean)}
|
||||
onSelect={() => manualCards.set(billIds, null)}
|
||||
>Card of the last payment</ContextMenuItem
|
||||
>
|
||||
</ContextMenuSubContent>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
const AUTO = "\u0000";
|
||||
|
||||
let { billId, cards }: { billId: string; cards: Card[] } = $props();
|
||||
let { billIds, cards }: { billIds: string[]; cards: Card[] } = $props();
|
||||
|
||||
let text = $state("");
|
||||
let input = $state<HTMLInputElement>();
|
||||
@@ -31,21 +31,23 @@
|
||||
input?.focus();
|
||||
});
|
||||
|
||||
const cancel = () => (manualCards.picking = "");
|
||||
const cancel = () => (manualCards.picking = null);
|
||||
</script>
|
||||
|
||||
<SuggestInput
|
||||
aria-label="Paying card"
|
||||
class="w-56"
|
||||
id="bill-card-{billId}"
|
||||
id="bill-card-{billIds[0]}"
|
||||
onaccept={(item) => {
|
||||
manualCards.set(billId, item.insert === AUTO ? null : item.insert);
|
||||
manualCards.set(billIds, item.insert === AUTO ? null : item.insert);
|
||||
return true;
|
||||
}}
|
||||
onblur={cancel}
|
||||
oncancel={cancel}
|
||||
onsubmit={cancel}
|
||||
placeholder="Paying card"
|
||||
placeholder={billIds.length > 1
|
||||
? `Paying card for ${billIds.length}`
|
||||
: "Paying card"}
|
||||
{suggestions}
|
||||
bind:input
|
||||
bind:value={text}
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { formatNumber, toNumber } from "#lib/format.js";
|
||||
import { display } from "#lib/money/display.svelte.js";
|
||||
|
||||
let {
|
||||
amount,
|
||||
code,
|
||||
symbol,
|
||||
decimals,
|
||||
approx = false,
|
||||
converted = false,
|
||||
exact = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
amount: number | string | null | undefined;
|
||||
code?: string | null;
|
||||
symbol?: string | null;
|
||||
decimals?: number | null;
|
||||
approx?: boolean;
|
||||
converted?: boolean;
|
||||
exact?: boolean;
|
||||
class?: string;
|
||||
} = $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>
|
||||
|
||||
<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"
|
||||
>{approx ? "~" : ""}{symbol ?? ""}</span
|
||||
>{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)}</span
|
||||
>{mark}{shown.symbol ?? ""}</span
|
||||
>{formatNumber(shown.amount, shown.decimals ?? 2)}</span
|
||||
>
|
||||
@@ -24,6 +24,7 @@
|
||||
import Calendar from "./Calendar.svelte";
|
||||
import CardMenu from "./CardMenu.svelte";
|
||||
import Money from "./Money.svelte";
|
||||
import Sums from "./Sums.svelte";
|
||||
|
||||
let {
|
||||
needs,
|
||||
@@ -69,8 +70,20 @@
|
||||
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: "xs", variant: on ? "default" : "outline" });
|
||||
buttonVariants({ size: "sm", variant: on ? "default" : "outline" });
|
||||
</script>
|
||||
|
||||
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
|
||||
@@ -122,7 +135,7 @@
|
||||
</p>
|
||||
{:else}
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger {oncontextmenu}>
|
||||
<ContextMenuTrigger class="outline-none" {oncontextmenu}>
|
||||
<div
|
||||
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
|
||||
>
|
||||
@@ -143,7 +156,7 @@
|
||||
<div
|
||||
class="font-semibold text-[26px] text-cladd-fg leading-tight"
|
||||
>
|
||||
<Money {...total} />
|
||||
<Money {...total} exact />
|
||||
</div>
|
||||
{/each}
|
||||
{#if need.short && need.currency}
|
||||
@@ -170,28 +183,33 @@
|
||||
{/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
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{chosen(due)}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -211,7 +229,7 @@
|
||||
>Edit</ContextMenuItem
|
||||
>
|
||||
<ContextMenuSeparator />
|
||||
<CardMenu billId={target.id} {cards} />
|
||||
<CardMenu billIds={[target.id]} {cards} />
|
||||
{/if}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -225,10 +243,7 @@
|
||||
>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
|
||||
>{renewals.length}, <Sums inline parts={renewalTotals} /></span
|
||||
>
|
||||
{#each renewals as due (due.bill.id)}
|
||||
<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}
|
||||
@@ -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 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;
|
||||
const PREF = "ffui_bill_cards";
|
||||
const LEGACY = "ffui.bill-card.";
|
||||
|
||||
type Choices = Record<string, string>;
|
||||
|
||||
function stored(): Choices {
|
||||
const data = session.prefs[PREF];
|
||||
return data && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Choices)
|
||||
: {};
|
||||
}
|
||||
|
||||
function legacyKeys(): string[] {
|
||||
return Array.from(
|
||||
{ length: localStorage.length },
|
||||
(_, i) => localStorage.key(i) ?? ""
|
||||
).filter((key) => key.startsWith(LEGACY));
|
||||
}
|
||||
|
||||
class ManualCards {
|
||||
ids = $state<Record<string, string>>(read());
|
||||
picking = $state("");
|
||||
pending = $state.raw<Choices | null>(null);
|
||||
picking = $state<{ at: string; ids: string[] } | null>(null);
|
||||
failure = $state("");
|
||||
ids = $derived(this.pending ?? stored());
|
||||
|
||||
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);
|
||||
async set(billIds: string[], cardId: string | null) {
|
||||
const changes = Object.fromEntries(billIds.map((id) => [id, cardId ?? ""]));
|
||||
this.picking = null;
|
||||
await this.save({ ...this.ids, ...changes });
|
||||
}
|
||||
|
||||
async migrate() {
|
||||
const keys = legacyKeys();
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.ids = Object.fromEntries(
|
||||
Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter(
|
||||
([, id]) => id
|
||||
)
|
||||
const old = Object.fromEntries(
|
||||
keys.map((key) => [
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface Due {
|
||||
bill: Item<Bill>;
|
||||
card: Card | null;
|
||||
date: string;
|
||||
lastPaid: string;
|
||||
manual: boolean;
|
||||
native: Sum;
|
||||
overdue: boolean;
|
||||
pay: Sum;
|
||||
@@ -238,6 +240,8 @@ export function dues(
|
||||
bill,
|
||||
card: who.card,
|
||||
date,
|
||||
lastPaid: who.last?.date ?? "",
|
||||
manual: who.manual,
|
||||
native,
|
||||
overdue,
|
||||
pay,
|
||||
|
||||
Reference in new issue
Block a user