feat(dashboard,accounts,charts): v1 dashboard redrawn, account lists and pages, svg chart kit

This commit is contained in:
hh committed 2026-10-16 03:50:02 +00:00
1 parent 62cd17ecde
commit 9e89829bae
36 files changed
+5678 -16

No files matched your search

@@ -0,0 +1,506 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type {
Account,
AccountType,
Item,
One,
Page,
PiggyBank,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import {
fieldErrors,
SELECT_CLASS,
} from "#lib/components/categories/form.js";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Textarea } from "#lib/components/ui/textarea/index.js";
import { today } from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import {
indexUrl,
LIABILITY_LABEL,
ROLE_LABEL,
showUrl,
TYPE_ONE,
} from "./links.js";
const DIRECTIONS = [
{ label: "I owe this debt to somebody else", value: "debit" },
{ label: "I am owed this debt", value: "credit" },
];
const PERIODS = [
{ label: "Per day", value: "daily" },
{ label: "Per week", value: "weekly" },
{ label: "Per month", value: "monthly" },
{ label: "Per quarter", value: "quarterly" },
{ label: "Per half year", value: "half-year" },
{ label: "Per year", value: "yearly" },
];
const CHECKBOX = "size-4 accent-[var(--ff-brand)]";
let { type, account }: { type: AccountType; account?: Item<Account> } =
$props();
const initial = untrack(() => account)?.attributes;
const blank = (value: string | null | undefined) => value ?? "";
const positive = (value: string | null | undefined) =>
value ? String(Math.abs(toNumber(value))) : "";
let name = $state(blank(initial?.name));
let currency = $state(initial?.currency_code ?? session.primary?.code ?? "");
let role = $state<string>(initial?.account_role ?? "defaultAsset");
let liabilityType = $state<string>(initial?.liability_type ?? "debt");
let direction = $state<string>(initial?.liability_direction ?? "debit");
let interest = $state(blank(initial?.interest) || "0");
let interestPeriod = $state<string>(initial?.interest_period ?? "monthly");
let iban = $state(blank(initial?.iban));
let bic = $state(blank(initial?.bic));
let number = $state(blank(initial?.account_number));
let opening = $state(
untrack(() => type) === "liabilities"
? positive(initial?.opening_balance)
: blank(initial?.opening_balance)
);
let openingDate = $state(
blank(initial?.opening_balance_date).slice(0, 10) || today()
);
let virtualBalance = $state(blank(initial?.virtual_balance));
let netWorth = $state(initial?.include_net_worth ?? true);
let notes = $state(blank(initial?.notes));
let active = $state(initial?.active ?? true);
let cardType = $state(initial?.credit_card_type ?? "monthlyFull");
let paymentDate = $state(
blank(initial?.monthly_payment_date).slice(0, 10) || today()
);
let again = $state(false);
let nameInput = $state<HTMLInputElement>();
let errors = $state<Record<string, string>>({});
let busy = $state(false);
let saved = $state("");
const piggies = createQuery(() => ({
enabled: Boolean(account),
queryFn: () =>
api<Page<PiggyBank>>(`/v1/accounts/${account?.id}/piggy-banks`, {
query: { limit: 1 },
}),
queryKey: ["account-piggies", account?.id],
}));
const locked = $derived((piggies.data?.meta.pagination?.total ?? 0) > 0);
const enabled = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === currency
)
);
const hasCurrency = $derived(type === "asset" || type === "liabilities");
const isCard = $derived(type === "asset" && role === "ccAsset");
const openingLabel = $derived(
direction === "credit" ? "I am owed amount" : "I owe amount"
);
const submitLabel = $derived(
`${account ? "Update" : "Store new"} ${TYPE_ONE[type]}`
);
function assetFields(): Record<string, unknown> {
return {
account_role: role,
virtual_balance: virtualBalance || null,
...(isCard
? { credit_card_type: cardType, monthly_payment_date: paymentDate }
: {}),
};
}
function liabilityFields(): Record<string, unknown> {
return {
interest: interest || "0",
interest_period: interestPeriod,
liability_direction: direction,
liability_type: liabilityType,
...(account && opening !== ""
? { liability_amount: opening, liability_start_date: openingDate }
: {}),
};
}
function body(): Record<string, unknown> {
const hasOpening = opening !== "";
return {
account_number: number || null,
bic: bic || null,
iban: iban || null,
name,
notes: notes || null,
type,
...(account ? { active } : {}),
...(hasCurrency
? { currency_code: currency, include_net_worth: netWorth }
: {}),
...(hasOpening || account
? {
opening_balance: hasOpening ? opening : null,
opening_balance_date: hasOpening ? openingDate : null,
}
: {}),
...(type === "asset" ? assetFields() : {}),
...(type === "liabilities" ? liabilityFields() : {}),
};
}
async function addToFrontpage(id: string) {
const ids = session.frontpageAccounts;
if (!("frontpageAccounts" in session.prefs) || ids.includes(id)) {
return;
}
await session.setPreference("frontpageAccounts", [...ids, id].map(Number));
}
function reset() {
name = "";
iban = "";
bic = "";
number = "";
opening = "";
virtualBalance = "";
notes = "";
}
async function save(event?: Event) {
event?.preventDefault();
if (busy) {
return;
}
busy = true;
errors = {};
saved = "";
try {
const result = await api<One<Account>>(
account ? `/v1/accounts/${account.id}` : "/v1/accounts",
{ body: body(), method: account ? "PUT" : "POST" }
);
if (!account && type === "asset") {
await addToFrontpage(result.data.id);
}
await invalidate();
if (again) {
saved = `Saved ${result.data.attributes.name}`;
if (!account) {
reset();
}
return;
}
await goto(showUrl(result.data.id));
} catch (failure) {
errors = fieldErrors(failure);
} finally {
busy = false;
}
}
async function remove() {
if (!account) {
return;
}
try {
await api(`/v1/accounts/${account.id}`, { method: "DELETE" });
await invalidate();
await goto(indexUrl(type));
} catch (failure) {
errors = fieldErrors(failure);
}
}
$effect(() => {
if (!untrack(() => account)) {
nameInput?.focus();
}
});
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: submitLabel,
run: () => save(),
},
]);
</script>
<form class="flex flex-col gap-3" onsubmit={save}>
<div class="grid gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-3" title="Mandatory fields">
<Field error={errors.name} for="name" label="Name">
<input
autocomplete="off"
class={INPUT}
id="name"
required
bind:this={nameInput}
bind:value={name}
>
</Field>
{#if hasCurrency}
<Field
error={errors.currency_code ?? errors.currency_id}
for="currency"
label="Currency"
>
{#if locked}
<p class="py-1.5 text-cladd-fg">
{currency}
<span class="text-cladd-fg-soft"
>· piggy banks are linked, so the currency stays</span
>
</p>
{:else}
<select class={SELECT_CLASS} id="currency" bind:value={currency}>
{#each enabled as cur (cur.id)}
<option value={cur.attributes.code}>
{cur.attributes.name}
({cur.attributes.symbol})
</option>
{/each}
</select>
{/if}
</Field>
{/if}
{#if type === "asset"}
<Field error={errors.account_role} for="role" label="Account role">
<select class={SELECT_CLASS} id="role" bind:value={role}>
{#each Object.entries(ROLE_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</Field>
{/if}
{#if type === "liabilities"}
<Field
error={errors.liability_type}
for="liability-type"
label="Liability type"
>
<select
class={SELECT_CLASS}
id="liability-type"
bind:value={liabilityType}
>
{#each Object.entries(LIABILITY_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</Field>
<Field
error={errors.liability_direction}
for="direction"
label="Liability in/out"
>
<select class={SELECT_CLASS} id="direction" bind:value={direction}>
{#each DIRECTIONS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</Field>
<div class="grid gap-3 sm:grid-cols-2">
<Field
error={errors.opening_balance ?? errors.liability_amount}
for="opening"
label={openingLabel}
>
<input
class="{INPUT} num text-right"
id="opening"
inputmode="decimal"
bind:value={opening}
>
</Field>
<Field
error={errors.opening_balance_date ?? errors.liability_start_date}
for="opening-date"
label="Start date of debt"
>
<DateInput id="opening-date" bind:value={openingDate} />
</Field>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<Field error={errors.interest} for="interest" label="Interest (%)">
<input
class="{INPUT} num text-right"
id="interest"
inputmode="decimal"
bind:value={interest}
>
</Field>
<Field
error={errors.interest_period}
for="interest-period"
label="Interest period"
>
<select
class={SELECT_CLASS}
id="interest-period"
bind:value={interestPeriod}
>
{#each PERIODS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</Field>
</div>
{/if}
</Box>
<Box body="flex flex-col gap-3 p-3" title="Optional fields">
<div class="grid gap-3 sm:grid-cols-[2fr_1fr]">
<Field error={errors.iban} for="iban" label="IBAN">
<input
autocomplete="off"
class="{INPUT} num"
id="iban"
bind:value={iban}
>
</Field>
<Field error={errors.bic} for="bic" label="BIC">
<input
autocomplete="off"
class="{INPUT} num"
id="bic"
maxlength="11"
bind:value={bic}
>
</Field>
</div>
<Field
error={errors.account_number}
for="number"
label={isCard ? "Credit card number" : "Account number"}
>
<input
autocomplete="off"
class="{INPUT} num"
id="number"
bind:value={number}
>
</Field>
{#if type === "asset"}
<div class="grid gap-3 sm:grid-cols-2">
<Field
error={errors.opening_balance}
for="opening"
label="Opening balance"
>
<input
class="{INPUT} num text-right"
id="opening"
inputmode="decimal"
bind:value={opening}
>
</Field>
<Field
error={errors.opening_balance_date}
for="opening-date"
label="Opening balance date"
>
<DateInput id="opening-date" bind:value={openingDate} />
</Field>
</div>
<Field
error={errors.virtual_balance}
for="virtual"
label="Virtual balance"
>
<input
class="{INPUT} num text-right"
id="virtual"
inputmode="decimal"
bind:value={virtualBalance}
>
</Field>
{/if}
{#if hasCurrency}
<label class="flex items-center gap-2 text-[13px]">
<input class={CHECKBOX} type="checkbox" bind:checked={netWorth}>
Include in net worth
</label>
{/if}
{#if account}
<label class="flex items-center gap-2 text-[13px]">
<input class={CHECKBOX} type="checkbox" bind:checked={active}>
Active
</label>
{/if}
<Field error={errors.notes} for="notes" label="Notes">
<Textarea id="notes" rows={4} bind:value={notes} />
</Field>
</Box>
</div>
{#if isCard}
<div class="grid gap-3 lg:grid-cols-2">
<span class="hidden lg:block"></span>
<Box body="flex flex-col gap-3 p-3" title="Credit card options">
<Field
error={errors.credit_card_type}
for="card-type"
label="Credit card payment plan"
>
<select class={SELECT_CLASS} id="card-type" bind:value={cardType}>
<option value="monthlyFull">Full payment every month</option>
</select>
</Field>
<Field
error={errors.monthly_payment_date}
for="payment-date"
label="Credit card monthly payment date"
>
<DateInput id="payment-date" bind:value={paymentDate} />
</Field>
</Box>
</div>
{/if}
<Box body="flex flex-wrap items-center gap-3 p-3" title="Options">
<Button disabled={busy} type="submit">{submitLabel}</Button>
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
<label class="flex items-center gap-2 text-[13px]">
<input class={CHECKBOX} type="checkbox" bind:checked={again}>
{account
? "Return here after updating"
: "Return here to create another one"}
</label>
{#if busy}
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
{:else if errors.form}
<span class="text-[12px] text-expense" role="alert">{errors.form}</span>
{:else if saved}
<span class="text-[12px] text-income" role="status">{saved}</span>
{:else if Object.keys(errors).length > 0}
<span class="text-[12px] text-expense" role="alert"
>Some fields need fixing</span
>
{/if}
{#if account}
<span class="ml-auto">
<ConfirmButton
confirmLabel="Press again: delete with its transactions"
onconfirm={remove}
>Delete</ConfirmButton
>
</span>
{/if}
</Box>
</form>
@@ -0,0 +1,407 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import LineChart from "#lib/components/charts/LineChart.svelte";
import {
axis,
balanceDate,
type ChartSet,
currency,
hasPrimary,
money,
seriesPoints,
setPrimary,
span,
} from "#lib/components/dashboard/data.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { contains, daysBetween, today } from "#lib/dates.js";
import { formatMoney, formatRange } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Breakdown from "./Breakdown.svelte";
import {
canReconcile,
createUrl,
editUrl,
go,
indexUrl,
LIABILITY_LABEL,
openOld,
ROLE_LABEL,
reconcileUrl,
showUrl,
TYPE_TITLE,
typeOf,
} from "./links.js";
import PeriodList from "./PeriodList.svelte";
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
const BUTTON =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
let { id, everything = false }: { id: string; everything?: boolean } =
$props();
let failure = $state("");
const until = $derived(balanceDate(period.start, period.end));
const account = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<One<Account>>(`/v1/accounts/${id}`, {
query: everything ? { date: today() } : { date: until },
}),
queryKey: ["account", id, everything ? "all" : until],
}));
const a = $derived(account.data?.data.attributes);
const type = $derived(a ? typeOf(a) : "asset");
const item = $derived(account.data?.data);
const first = createQuery(() => ({
enabled: everything,
queryFn: async () => {
const head = await api<Page<TransactionGroup>>(
`/v1/accounts/${id}/transactions`,
{ query: { limit: 1 } }
);
const pages = head.meta.pagination?.total_pages ?? 1;
const last =
pages > 1
? await api<Page<TransactionGroup>>(
`/v1/accounts/${id}/transactions`,
{
query: { limit: 1, page: pages },
}
)
: head;
return (
last.data[0]?.attributes.transactions[0]?.date?.slice(0, 10) ?? today()
);
},
queryKey: ["account-first", id],
}));
const range = $derived(
everything
? { end: today(), start: first.data ?? today() }
: { end: period.end, start: period.start }
);
const step = $derived.by(() => {
const days = daysBetween(range.start, range.end);
if (days <= 92) {
return "1D";
}
if (days <= 730) {
return "1W";
}
return days <= 3000 ? "1M" : "1Y";
});
const chart = createQuery(() => ({
enabled: !everything || first.isSuccess,
placeholderData: keepPreviousData,
queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", {
query: {
"accounts[]": id,
end: range.end > range.start ? range.end : range.start,
period: step,
start: range.start,
},
}),
queryKey: ["account-chart", id, range.start, range.end, step],
}));
const converted = $derived(hasPrimary(chart.data ?? []));
const series = $derived(
(chart.data ?? []).map((set) => ({
area: true,
format: money(converted ? setPrimary(set) : currency(set.currency_code)),
key: set.label,
label: set.label,
points: seriesPoints(set, converted),
}))
);
const cur = $derived(
currency(a?.currency_code ?? session.primary?.code ?? "")
);
const pageNo = $derived(
Number(page.url.searchParams.get("page") ?? "1") || 1
);
const dated = createQuery(() => ({
enabled: !everything,
placeholderData: keepPreviousData,
queryFn: () =>
all<TransactionGroup>(
`/v1/accounts/${id}/transactions`,
span(period.start, period.end)
).then((list) =>
list.filter((g) =>
g.attributes.transactions.some((s) => contains(period.range, s.date))
)
),
queryKey: ["account-tx-all", id, period.start, period.end],
}));
const paged = createQuery(() => ({
enabled: everything,
placeholderData: keepPreviousData,
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
query: { limit: session.pageSize, page: pageNo },
}),
queryKey: ["account-tx-page", id, pageNo, session.pageSize],
}));
const groups = $derived(
everything ? (paged.data?.data ?? []) : (dated.data ?? [])
);
const pages = $derived(paged.data?.meta.pagination?.total_pages ?? 1);
const total = $derived(
everything ? (paged.data?.meta.pagination?.total ?? 0) : groups.length
);
$effect(() => {
if (!(a && SPECIAL.has(String(a.type)))) {
return;
}
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
query: { limit: 1 },
}).then((res) => {
const split = res.data[0]?.attributes.transactions[0];
const other =
split?.source_id === id ? split?.destination_id : split?.source_id;
goto(other ? showUrl(other) : "/", { replace: true });
});
});
const marker = $derived(contains(range, today()) ? today() : undefined);
const subtitle = $derived.by(() => {
if (!a) {
return "";
}
if (type === "asset") {
return ROLE_LABEL[a.account_role ?? ""] ?? "";
}
if (type === "liabilities") {
return LIABILITY_LABEL[a.liability_type ?? ""] ?? "";
}
return TYPE_TITLE[type].replace(" accounts", " account");
});
const balance = $derived(
a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
);
function pageHref(n: number) {
const url = new URL(page.url.href);
url.searchParams.set("page", String(n));
return url.pathname + url.search;
}
async function remove() {
failure = "";
try {
await api(`/v1/accounts/${id}`, { method: "DELETE" });
await invalidate();
goto(indexUrl(type));
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
}
}
useKeys(() => [
{
group: "Account",
keys: "E",
label: "Edit this account",
run: () => goto(editUrl(id)),
},
...(type === "asset"
? [
{
group: "Account",
keys: "R",
label: "Reconcile this account",
run: () => go(reconcileUrl(id)),
},
]
: []),
{
group: "Account",
keys: "A",
label: everything ? "Show the current period" : "Show all transactions",
run: () => goto(everything ? showUrl(id) : `${showUrl(id)}/all`),
},
]);
</script>
<PageHeader
crumbs={[
{ href: indexUrl(type), label: TYPE_TITLE[type] },
...(everything
? [{ label: a?.name ?? "" }]
: [
{ href: `${showUrl(id)}/all`, label: a?.name ?? "" },
{ label: formatRange(period.start, period.end) },
]),
]}
{subtitle}
title={a?.name ?? "Account"}
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-1.5">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="{BUTTON} cladd-color-green text-cladd-primary"
>
Create <ChevronDown class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem onSelect={() => go(createUrl("withdrawal", id))}
>New withdrawal</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("deposit", id))}
>New deposit</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("transfer", id))}
>New transfer</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu.Root>
<a class={BUTTON} href={editUrl(id)} title="Edit Shift+E">Edit</a>
{#if item && canReconcile(item)}
<a class={BUTTON} href={reconcileUrl(id)} title="Reconcile Shift+R"
>Reconcile</a
>
{/if}
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
<button
class={BUTTON}
onclick={() => openOld(page.url.pathname)}
type="button"
>
Old UI
</button>
</div>
{/snippet}
</PageHeader>
{#if failure}
<p class="pb-3 text-expense">{failure}</p>
{/if}
<div class="flex flex-col gap-3">
<Box
title={a
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
: "Chart"}
>
<div
class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
>
{#if chart.isPending || (everything && first.isPending)}
<div class="h-[300px]"></div>
{:else if chart.isError}
<p class="text-expense">{chart.error.message}</p>
{:else if series.length === 0}
<p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
{:else}
<LineChart
axisFormat={axis(
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
)}
height={300}
label="Balance of {a?.name ?? "the account"}"
{marker}
{series}
toggle={false}
/>
{/if}
</div>
</Box>
{#if !everything}
<div class="grid gap-3 md:grid-cols-3">
<Breakdown
by="category"
{groups}
kind="withdrawal"
title="Expenses by category"
/>
<Breakdown
by="budget"
{groups}
kind="withdrawal"
title="Expenses by budget"
/>
<Breakdown
by="category"
{groups}
kind="deposit"
title="Income by category"
/>
</div>
{/if}
{#if a?.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap text-cladd-fg">{a.notes}</p>
</Box>
{/if}
<div
class="grid gap-3 {everything
? ""
: "min-[1700px]:grid-cols-[minmax(0,1fr)_15rem]"}"
>
<Box body="p-0" title="Transactions">
{#snippet tools()}
<span class="num text-[12px] text-cladd-fg-soft"
>{total}
transactions</span
>
{#if everything}
<Pager href={pageHref} keys page={pageNo} {pages} />
{/if}
{/snippet}
<TransactionTable
context={{ accountId: id }}
empty={everything
? "No transactions."
: "No transactions in this period."}
{groups}
id="account-tx"
loading={everything ? paged.isFetching : dated.isFetching}
/>
{#snippet footer()}
{#if everything}
<a class="text-brand hover:underline" href={showUrl(id)}
>Show the current period and overview</a
>
<span class="ml-auto"
><Pager href={pageHref} page={pageNo} {pages} /></span
>
{:else}
<a class="text-brand hover:underline" href="{showUrl(id)}/all"
>Show all transactions without grouping them by date</a
>
{/if}
{/snippet}
</Box>
{#if !everything && a}
<div class="order-first min-w-0 min-[1700px]:order-none">
<PeriodList accountId={id} code={a.currency_code ?? ""} />
</div>
{/if}
</div>
</div>
@@ -0,0 +1,692 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import Check from "@lucide/svelte/icons/check";
import Plus from "@lucide/svelte/icons/plus";
import X from "@lucide/svelte/icons/x";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Account, AccountType, Item, Page } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import {
balanceRange,
type ChartSet,
seriesPoints,
} from "#lib/components/dashboard/data.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { daysBetween } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
DIRECTION_LABEL,
editUrl,
formatIban,
go,
LIABILITY_LABEL,
openOld,
PERIOD_LABEL,
ROLE_LABEL,
reconcileUrl,
showUrl,
TYPE_ONE,
TYPE_TITLE,
} from "./links.js";
type Key =
| "name"
| "role"
| "liability"
| "direction"
| "interest"
| "number"
| "balance"
| "due"
| "active"
| "activity"
| "trend"
| "difference";
interface Column {
key: Key;
label: string;
num?: boolean;
wide?: boolean;
}
const COLUMNS: Record<Key, Column> = {
active: { key: "active", label: "Is active?" },
activity: { key: "activity", label: "Last activity", wide: true },
balance: { key: "balance", label: "Current balance", num: true },
difference: { key: "difference", label: "Balance difference", num: true },
direction: { key: "direction", label: "Liability in/out" },
due: { key: "due", label: "Amount due", num: true },
interest: { key: "interest", label: "Interest (period)", wide: true },
liability: { key: "liability", label: "Type of liability" },
name: { key: "name", label: "Name" },
number: { key: "number", label: "Account number", wide: true },
role: { key: "role", label: "Role", wide: true },
trend: { key: "trend", label: "In this period", num: true, wide: true },
};
const LAYOUT: Record<AccountType, Key[]> = {
asset: [
"name",
"role",
"number",
"balance",
"active",
"activity",
"trend",
"difference",
],
expense: ["name", "number", "balance", "active", "activity", "difference"],
liabilities: [
"name",
"liability",
"direction",
"interest",
"number",
"balance",
"due",
"active",
"trend",
"difference",
],
revenue: ["name", "number", "balance", "active", "activity", "difference"],
};
let { type, active = true }: { type: AccountType; active?: boolean } =
$props();
let filterInput = $state<HTMLInputElement>();
let filter = $state(page.url.searchParams.get("filter") ?? "");
let menu = $state<Item<Account> | null>(null);
let armed = $state(false);
let failure = $state("");
const accounts = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
all<Account>("/v1/accounts", {
...balanceRange(period.start, period.end),
active: String(active),
type,
}),
queryKey: ["accounts-index", type, active, period.start, period.end],
}));
const others = createQuery(() => ({
queryFn: () =>
api<Page<Account>>("/v1/accounts", {
query: { active: String(!active), limit: 1, type },
}),
queryKey: ["accounts-count", type, !active],
}));
const charted = $derived(
(type === "asset" || type === "liabilities") && active
? (accounts.data ?? []).map((item) => item.id)
: []
);
const trendChart = createQuery(() => ({
enabled: charted.length > 0 && period.start < period.end,
queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", {
query: {
"accounts[]": charted,
end: period.end,
period: daysBetween(period.start, period.end) > 92 ? "1W" : "1D",
start: period.start,
},
}),
queryKey: ["accounts-trend", charted.join(","), period.start, period.end],
}));
const trends = $derived(
new Map(
(trendChart.data ?? []).map((set) => [
set.label,
seriesPoints(set, false).map((p) => p.y),
])
)
);
const otherCount = $derived(others.data?.meta.pagination?.total ?? 0);
const columns = $derived(LAYOUT[type].map((k) => COLUMNS[k]));
const sortParam = $derived(page.url.searchParams.get("sort") ?? "");
const sortKey = $derived(sortParam.replace("-", "") as Key | "");
const descending = $derived(sortParam.startsWith("-"));
function sortValue(item: Item<Account>, key: Key): string | number {
const a = item.attributes;
switch (key) {
case "name":
return a.name.toLowerCase();
case "role":
return ROLE_LABEL[a.account_role ?? ""] ?? "";
case "liability":
return a.liability_type ?? "";
case "direction":
return a.liability_direction ?? "";
case "interest":
return toNumber(a.interest);
case "number":
return formatIban(a);
case "balance":
return toNumber(a.current_balance);
case "due":
return toNumber(a.debt_amount);
case "active":
return a.active ? 1 : 0;
case "activity":
return a.last_activity ?? "";
default:
return toNumber(a.balance_difference);
}
}
const needle = $derived(filter.trim().toLowerCase());
const matching = $derived(
(accounts.data ?? []).filter((item) => {
if (!needle) {
return true;
}
const a = item.attributes;
return [
a.name,
a.iban,
a.account_number,
ROLE_LABEL[a.account_role ?? ""],
]
.filter(Boolean)
.some((text) => String(text).toLowerCase().includes(needle));
})
);
const sorted = $derived.by(() => {
const list = [...matching];
if (!sortKey) {
return list.sort(
(x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
);
}
const dir = descending ? -1 : 1;
return list.sort((x, y) => {
const a = sortValue(x, sortKey);
const b = sortValue(y, sortKey);
if (a === b) {
return 0;
}
return (a > b ? 1 : -1) * dir;
});
});
const grouped = $derived(
sorted.some((item) => item.attributes.object_group_title)
);
const groups = $derived.by(() => {
if (!grouped) {
return [{ items: sorted, title: "" }];
}
const map = new Map<string, { items: Item<Account>[]; order: number }>();
for (const item of sorted) {
const name = item.attributes.object_group_title ?? "";
const entry = map.get(name) ?? {
items: [],
order: name ? (item.attributes.object_group_order ?? 0) : 1e9,
};
entry.items.push(item);
map.set(name, entry);
}
return [...map.entries()]
.sort(([, a], [, b]) => a.order - b.order)
.map(([name, entry]) => ({
items: entry.items,
title: name || "Without group",
}));
});
const rows = $derived(groups.flatMap((g) => g.items));
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
interface Total {
balance: number;
code: string;
decimals: number;
difference: number;
due: number;
symbol: string;
}
const totals = $derived.by(() => {
const map = new Map<string, Total>();
for (const { attributes: a } of rows) {
const code = a.currency_code ?? "";
const t = map.get(code) ?? {
balance: 0,
code,
decimals: a.currency_decimal_places ?? 2,
difference: 0,
due: 0,
symbol: a.currency_symbol ?? code,
};
t.balance += toNumber(a.current_balance);
t.difference += toNumber(a.balance_difference);
t.due += toNumber(a.debt_amount);
map.set(code, t);
}
return [...map.values()];
});
const title = $derived(
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
);
const createHref = $derived(`/accounts/create/${type}`);
const nav = new ListNav<Item<Account>>({
edit: (item) => goto(editUrl(item.id)),
extra: () => [
{
group: "Accounts",
keys: "/",
label: "Filter accounts",
run: () => filterInput?.focus(),
},
{
group: "Accounts",
keys: "c",
label: `Create a new ${TYPE_ONE[type]}`,
run: () => goto(createHref),
},
{
group: "Accounts",
keys: "r",
label: "Reconcile account",
run: () => {
if (nav.current && type === "asset") {
go(reconcileUrl(nav.current.id));
}
},
},
],
id: "accounts",
items: () => rows,
key: (item) => item.id,
open: (item) => goto(showUrl(item.id)),
});
nav.use();
function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) {
filter = "";
onfilter();
} else if (event.key === "Enter" && rows[0]) {
event.preventDefault();
goto(showUrl(rows[0].id));
}
}
function ariaSort(key: Key) {
if (sortKey !== key) {
return;
}
return descending ? "descending" : "ascending";
}
function sortHref(key: Key): string {
const url = new URL(page.url.href);
let next: string | null = key;
if (sortKey === key) {
next = descending ? null : `-${key}`;
}
if (next) {
url.searchParams.set("sort", next);
} else {
url.searchParams.delete("sort");
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
return url.pathname + url.search;
}
function onfilter() {
const url = new URL(page.url.href);
if (filter) {
url.searchParams.set("filter", filter);
} else {
url.searchParams.delete("filter");
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
nav.cursor = -1;
goto(url, { replace: true, shallow: true, state: page.state });
}
function oncontextmenu(event: MouseEvent) {
const tr = (event.target as Element).closest("tr[data-id]");
armed = false;
menu = rows.find((r) => r.id === tr?.getAttribute("data-id")) ?? null;
if (menu) {
nav.cursor = index.get(menu.id) ?? -1;
}
}
async function remove(item: Item<Account>) {
failure = "";
try {
await api(`/v1/accounts/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
}
}
function money(a: Account, value: string | number | null | undefined) {
return {
amount: value,
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
};
}
function rowClass(i: number) {
if (nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
return "hover:bg-row-hover";
}
</script>
<PageHeader
crumbs={[{ label: title }]}
subtitle={active ? undefined : "Archive"}
title="Accounts"
>
{#snippet actions()}
<a
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
href={createHref}
title="Create a new {TYPE_ONE[type]} c"
>
<Plus class="size-3.5" />
Create a new {TYPE_ONE[type]}
</a>
{/snippet}
</PageHeader>
<Box body="p-0" {title}>
{#snippet tools()}
<input
aria-label="Filter accounts"
class="cladd-cut h-7 w-44 rounded-cladd-sm px-2 text-[12px] outline-none placeholder:text-cladd-fg-softer sm:w-60"
oninput={onfilter}
onkeydown={onfilterkey}
placeholder="Filter /"
type="search"
bind:this={filterInput}
bind:value={filter}
>
{/snippet}
{#if accounts.isPending}
<div class="h-40"></div>
{:else if accounts.isError}
<p class="p-3 text-expense">{accounts.error.message}</p>
{:else if (accounts.data?.length ?? 0) === 0}
<div class="flex flex-col items-center gap-3 px-3 py-10 text-center">
<p class="font-semibold text-[15px]">
{active
? `No ${TYPE_TITLE[type].toLowerCase()} yet`
: "Nothing archived"}
</p>
{#if active}
<a class="text-brand underline" href={createHref}
>Create a new {TYPE_ONE[type]}</a
>
{/if}
</div>
{:else}
<ContextMenu>
<ContextMenuTrigger class="block overflow-x-auto" {oncontextmenu}>
<table
class="w-full min-w-[720px] border-collapse text-[13px] transition-opacity {accounts.isPlaceholderData
? "opacity-60"
: ""}"
>
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8">
{#each columns as col (col.key)}
<th
aria-sort={ariaSort(col.key)}
class="whitespace-nowrap px-3 font-semibold text-[12px] text-cladd-fg-soft {col.num
? "text-right"
: ""} {col.wide ? "hidden md:table-cell" : ""}"
scope="col"
>
<a
class="inline-flex items-center gap-1 hover:text-cladd-fg"
href={sortHref(col.key)}
>{col.label}
{#if sortKey === col.key}
{#if descending}
<ArrowDown class="size-3" />
{:else}
<ArrowUp class="size-3" />
{/if}
{/if}
</a>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each groups as group (group.title)}
{#if grouped}
<tr class="h-8 border-cladd-outline border-t">
<th
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
colspan={columns.length}
scope="rowgroup"
>
{group.title}
</th>
</tr>
{/if}
{#each group.items as item (item.id)}
{@const a = item.attributes}
{@const i = index.get(item.id) ?? -1}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
data-id={item.id}
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
>
{#each columns as col (col.key)}
<td
class="px-3 {col.key === "name"
? ""
: "whitespace-nowrap"} {col.num
? "text-right"
: ""} {col.wide ? "hidden md:table-cell" : ""}"
>
{#if col.key === "name"}
<a
class="block max-w-[22rem] truncate text-brand hover:underline"
href={showUrl(item.id)}
title={a.name}
>{a.name}</a
>
{:else if col.key === "role"}
{ROLE_LABEL[a.account_role ?? ""] ?? ""}
{:else if col.key === "liability"}
{LIABILITY_LABEL[a.liability_type ?? ""] ?? ""}
{:else if col.key === "direction"}
{DIRECTION_LABEL[a.liability_direction ?? ""] ?? ""}
{:else if col.key === "interest"}
<span class="num">{toNumber(a.interest)}%</span>
<span class="text-cladd-fg-soft"
>({PERIOD_LABEL[a.interest_period ?? ""] ??
a.interest_period})</span
>
{:else if col.key === "number"}
<span class="num text-[12px] text-cladd-fg-soft"
>{formatIban(a)}</span
>
{:else if col.key === "balance"}
<Amount {...money(a, a.current_balance)} />
{:else if col.key === "due"}
<Amount {...money(a, a.debt_amount)} tone="transfer" />
{:else if col.key === "active"}
{#if a.active}
<Check aria-label="Active" class="size-3.5" />
{:else}
<X
aria-label="Inactive"
class="size-3.5 text-cladd-fg-soft"
/>
{/if}
{:else if col.key === "trend"}
{@const values = trends.get(a.name) ?? []}
<span class="flex justify-end">
<Sparkline
color={toNumber(a.balance_difference) < 0
? "var(--ff-expense)"
: "var(--ff-income)"}
height={20}
{values}
width={88}
/>
</span>
{:else if col.key === "activity"}
<span class="text-cladd-fg-soft"
>{a.last_activity
? formatDate(a.last_activity.slice(0, 10))
: "never"}</span
>
{:else}
<Amount {...money(a, a.balance_difference)} />
{/if}
</td>
{/each}
</tr>
{/each}
{/each}
{#if rows.length === 0}
<tr>
<td
class="px-3 py-6 text-center text-cladd-fg-soft"
colspan={columns.length}
>
Nothing matches “{filter}”. Press Escape to clear.
</td>
</tr>
{/if}
</tbody>
{#if totals.length > 0}
<tfoot class="border-cladd-outline border-t-2">
{#each totals as t (t.code)}
<tr class="h-7">
{#each columns as col, c (col.key)}
<td
class="px-3 {col.num ? "text-right" : ""} {col.wide
? "hidden md:table-cell"
: ""}"
>
{#if c === 0}
<span
class="font-semibold text-[12px] text-cladd-fg-soft"
>Total in {t.code}</span
>
{:else if col.key === "balance"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.balance })}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.due })}</span
>
{:else if col.key === "difference"}
<Amount
{...t}
amount={t.difference}
class="font-semibold"
/>
{/if}
</td>
{/each}
</tr>
{/each}
</tfoot>
{/if}
</table>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menu}
{@const target = menu}
<ContextMenuItem onSelect={() => goto(showUrl(target.id))}>
Show <ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onSelect={() => goto(editUrl(target.id))}>
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
</ContextMenuItem>
{#if type === "asset"}
<ContextMenuItem onSelect={() => go(reconcileUrl(target.id))}>
Reconcile <ContextMenuShortcut>r</ContextMenuShortcut>
</ContextMenuItem>
{/if}
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
if (armed) {
remove(target);
return;
}
event.preventDefault();
armed = true;
}}
variant="destructive"
>
{armed
? `Press again to delete ${target.attributes.name}`
: "Delete"}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => openOld(showUrl(target.id))}>
Open in old UI
</ContextMenuItem>
{:else}
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a class="text-brand hover:underline" href={createHref}
>Create a new {TYPE_ONE[type]}</a
>
{#if failure}
<span class="text-expense">{failure}</span>
{/if}
{#if active && otherCount > 0}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{type}"
>{otherCount}
inactive {otherCount === 1 ? "account" : "accounts"}</a
>
{:else if !active}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/accounts/{type}"
>Back to active {TYPE_TITLE[type].toLowerCase()}</a
>
{/if}
{/snippet}
</Box>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { Split } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import {
byCurrency,
currency,
money,
} from "#lib/components/dashboard/data.js";
import type { Group } from "#lib/transactions/split.js";
const TOP = 6;
let {
title,
groups,
kind,
by,
}: {
title: string;
groups: Group[];
kind: "withdrawal" | "deposit";
by: "category" | "budget";
} = $props();
function pick(split: Split): [string, string] {
if (by === "budget") {
return split.budget_id
? [split.budget_name ?? "", `/budgets/show/${split.budget_id}`]
: ["(no budget)", "/budgets/list/no-budget"];
}
return split.category_id
? [split.category_name ?? "", `/categories/show/${split.category_id}`]
: ["(no category)", "/categories/list/no-category"];
}
const splits = $derived(
groups
.flatMap((g) => g.attributes.transactions)
.filter((s) => s.type === kind)
);
const sections = $derived(
byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
const sums = new Map<string, Bar>();
for (const split of list) {
const [label, href] = pick(split);
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
bar.value += Number(split.amount);
sums.set(label, bar);
}
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
const rest = sorted.slice(TOP);
const bars =
rest.length > 1
? [
...sorted.slice(0, TOP),
{
key: "other",
label: `Other (${rest.length})`,
value: rest.reduce((sum, b) => sum + b.value, 0),
},
]
: sorted;
return { bars, code, format: money(currency(code)) };
})
);
</script>
<Box {title}>
{#if sections.length === 0}
<p class="py-4 text-center text-cladd-fg-soft">
{kind === "withdrawal" ? "No expenses" : "No income"}
in this period.
</p>
{:else}
<div class="flex flex-col gap-3">
{#each sections as section (section.code)}
<div class="flex flex-col gap-1">
{#if sections.length > 1}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{section.code}</span
>
{/if}
<BarChart
bars={section.bars}
color={kind === "withdrawal" ? tones.expense : tones.income}
format={section.format}
label="{title} in {section.code}"
labelWidth="minmax(5rem, 9rem)"
orientation="horizontal"
/>
</div>
{/each}
</div>
{/if}
</Box>
@@ -0,0 +1,153 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import Box from "#lib/components/Box.svelte";
import { tones } from "#lib/components/charts/chart.js";
import {
type ChartSet,
currency,
money,
} from "#lib/components/dashboard/data.js";
import {
isDynamic,
periodOf,
type Range,
shiftPeriod,
type ViewRange,
} from "#lib/dates.js";
import { toNumber } from "#lib/format.js";
import { period, periodLabel } from "#lib/period.svelte.js";
const COUNT = 12;
const STEPS = new Set(["1D", "1W", "1M", "3M", "6M", "1Y"]);
let { accountId, code }: { accountId: string; code: string } = $props();
const view = $derived<ViewRange>(
isDynamic(period.view) || !STEPS.has(period.view) ? "1M" : period.view
);
const blocks = $derived.by<Range[]>(() => {
const list = [periodOf(period.end, view)];
for (let i = 1; i < COUNT; i += 1) {
list.push(shiftPeriod(list[i - 1], view, -1));
}
return list;
});
const oldest = $derived(blocks.at(-1)?.start ?? period.start);
const newest = $derived(blocks[0]?.end ?? period.end);
const flows = createQuery(() => ({
queryFn: () =>
api<ChartSet[]>("/v1/chart/balance/balance", {
query: {
"accounts[]": accountId,
end: newest,
period: view,
start: oldest,
},
}),
queryKey: ["account-flows", accountId, oldest, newest, view],
}));
interface Flow {
code: string;
earned: number;
spent: number;
}
const byStart = $derived.by(() => {
const map = new Map<string, Flow[]>();
for (const set of flows.data ?? []) {
const kind = set.label === "earned" ? "earned" : "spent";
for (const [date, value] of Object.entries(set.entries)) {
const key = date.slice(0, 10);
const list = map.get(key) ?? [];
let flow = list.find((f) => f.code === set.currency_code);
if (!flow) {
flow = { code: set.currency_code, earned: 0, spent: 0 };
list.push(flow);
map.set(key, list);
}
flow[kind] += Math.abs(toNumber(value));
}
}
for (const list of map.values()) {
list.sort((a, b) => Number(b.code === code) - Number(a.code === code));
}
return map;
});
const scale = $derived(
Math.max(
1,
...[...byStart.values()].flatMap((list) =>
list
.filter((f) => f.code === code)
.map((f) => Math.max(f.earned, f.spent))
)
)
);
const isCurrent = (block: Range) =>
block.start === period.start && block.end === period.end;
</script>
<Box body="p-0 @container" title="Periods">
<ul
class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6"
>
{#each blocks as block (block.start)}
{@const list = (byStart.get(block.start) ?? []).filter(
(f) => f.earned || f.spent
)}
<li class="border-cladd-outline/60 border-r border-b">
<a
class="flex flex-col gap-1 px-3 py-2 hover:bg-row-hover {isCurrent(
block
)
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
: ""}"
href="/accounts/show/{accountId}/{block.start}/{block.end}"
>
<span class="font-semibold text-[12px] text-cladd-fg"
>{periodLabel(block, view)}</span
>
{#if list.length === 0}
<span class="text-[12px] text-cladd-fg-softer"
>No transactions</span
>
{/if}
{#each list as flow (flow.code)}
{@const fmt = money(currency(flow.code))}
<span
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
>
{#if flow.earned}
<span class="text-income">+{fmt(flow.earned)}</span>
{/if}
{#if flow.spent}
<span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
{/if}
</span>
{#if flow.code === code}
<span aria-hidden="true" class="flex h-1 gap-0.5">
<span class="flex flex-1 justify-end">
<span
class="h-full rounded-l-full"
style:background={tones.income}
style:width="{(flow.earned / scale) * 100}%"
></span>
</span>
<span class="flex-1">
<span
class="block h-full rounded-r-full"
style:background={tones.expense}
style:width="{(flow.spent / scale) * 100}%"
></span>
</span>
</span>
{/if}
{/each}
</a>
</li>
{/each}
</ul>
</Box>
@@ -0,0 +1,591 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Account,
One,
Split,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { currency, money } from "#lib/components/dashboard/data.js";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { addDays } from "#lib/dates.js";
import { formatDate, toNumber } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import {
type Group,
groupTitle,
signedAmount,
signedForeign,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { indexUrl, showUrl, TYPE_TITLE } from "./links.js";
const MARGIN_DAYS = 3;
const CENT = 0.005;
interface Row {
amount: number;
cleared: boolean;
date: string;
group: Group;
inRange: boolean;
key: string;
split: Split;
}
let { id, start, end }: { id: string; start: string; end: string } = $props();
let startInput = $state(untrack(() => start));
let endInput = $state(untrack(() => end));
let startText = $state("");
let endText = $state("");
let startTouched = $state(false);
let endTouched = $state(false);
let correct = $state(false);
let busy = $state(false);
let status = $state("");
let failure = $state("");
const account = createQuery(() => ({
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
queryKey: ["account", id, "reconcile"],
}));
const a = $derived(account.data?.data.attributes);
const cur = $derived(currency(a?.currency_code ?? ""));
const fmt = $derived(money(cur));
const balanceAt = (date: string) => async () => {
const res = await api<One<Account>>(`/v1/accounts/${id}`, {
query: { date },
});
return toNumber(res.data.attributes.current_balance);
};
const before = createQuery(() => ({
queryFn: balanceAt(addDays(start, -1)),
queryKey: ["reconcile-balance", id, addDays(start, -1)],
staleTime: 0,
}));
const after = createQuery(() => ({
queryFn: balanceAt(end),
queryKey: ["reconcile-balance", id, end],
staleTime: 0,
}));
const list = createQuery(() => ({
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, {
end: addDays(end, MARGIN_DAYS),
start: addDays(start, -MARGIN_DAYS),
}),
queryKey: ["reconcile-list", id, start, end],
staleTime: 0,
}));
function net(split: Split): number {
const code = a?.currency_code;
if (split.currency_code !== code && split.foreign_currency_code === code) {
return signedForeign(split, id)?.amount ?? 0;
}
return signedAmount(split, id).amount;
}
const rows = $derived<Row[]>(
(list.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter((s) => s.source_id === id || s.destination_id === id)
.map((split) => {
const date = split.date.slice(0, 10);
return {
amount: net(split),
cleared: split.reconciled === true,
date,
group,
inRange: date >= start && date <= end,
key: split.transaction_journal_id ?? "",
split,
};
})
)
.sort((x, y) => y.split.date.localeCompare(x.split.date))
);
const open = $derived(rows.filter((r) => r.inRange && !r.cleared));
const later = $derived(rows.filter((r) => r.date > end));
const inside = $derived(rows.filter((r) => r.inRange));
const earlier = $derived(rows.filter((r) => r.date < start));
const nav = new ListNav<Row>({
extra: () => [
{
group: "Reconcile",
input: true,
keys: "mod+Enter",
label: "Store reconciliation",
run: () => store(),
},
],
id: "reconcile",
items: () => open,
key: (row) => row.key,
selectable: true,
});
nav.use();
useKeys(() => [
{
group: "Reconcile",
keys: "c",
label: "Toggle the correction",
run: () => {
correct = canCorrect && !correct;
},
},
]);
const startBalance = $derived(
startTouched ? toNumber(startText) : (before.data ?? 0)
);
const endBalance = $derived(
endTouched ? toNumber(endText) : (after.data ?? 0)
);
const selected = $derived(open.filter((r) => nav.isSelected(r)));
const selectedSum = $derived(selected.reduce((s, r) => s + r.amount, 0));
const cleared = $derived(inside.filter((r) => r.cleared));
const clearedSum = $derived(cleared.reduce((s, r) => s + r.amount, 0));
const sum = $derived(startBalance + selectedSum + clearedSum);
const difference = $derived.by(() => {
const value = sum - endBalance;
return Math.abs(value) < CENT ? 0 : value;
});
const reconciliationAccounts = createQuery(() => ({
queryFn: () => all<Account>("/v1/accounts", { type: "reconciliation" }),
queryKey: ["accounts", "reconciliation"],
}));
const reconciliationId = $derived(
reconciliationAccounts.data?.find(
(r) => r.attributes.name === `${a?.name} reconciliation (${cur.code})`
)?.id
);
const canCorrect = $derived(Boolean(reconciliationId));
const correcting = $derived(correct && canCorrect && difference !== 0);
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
function setDates(nextStart: string, nextEnd: string) {
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
return;
}
startTouched = false;
endTouched = false;
nav.clear();
goto(`/accounts/reconcile/${id}/index/${nextStart}/${nextEnd}`, {
replace: true,
});
}
async function clear(group: Group, journals: Set<string>) {
let splits = group.attributes.transactions;
let title = group.attributes.group_title;
if (title) {
const full = await api<One<TransactionGroup>>(
`/v1/transactions/${group.id}`
);
splits = full.data.attributes.transactions;
title = full.data.attributes.group_title || groupTitle(full.data);
}
const body: Record<string, unknown> = {
transactions: splits.map((s) =>
journals.has(s.transaction_journal_id ?? "")
? {
reconciled: true,
transaction_journal_id: s.transaction_journal_id,
}
: { transaction_journal_id: s.transaction_journal_id }
),
};
if (splits.length > 1) {
body.group_title = title;
}
await api(`/v1/transactions/${group.id}`, { body, method: "PUT" });
}
function correction(other: string) {
const sides =
difference > 0
? { destination_id: other, source_id: id }
: { destination_id: id, source_id: other };
return {
apply_rules: false,
fire_webhooks: false,
transactions: [
{
amount: Math.abs(difference).toFixed(cur.decimals),
currency_code: cur.code,
date: end,
description: `Reconciliation (${formatDate(start)} to ${formatDate(end)})`,
reconciled: true,
type: "reconciliation",
...sides,
},
],
};
}
async function store() {
if (busy || !ready || (selected.length === 0 && !correcting)) {
return;
}
busy = true;
failure = "";
const byGroup = new Map<string, { group: Group; journals: Set<string> }>();
for (const row of selected) {
const entry = byGroup.get(row.group.id) ?? {
group: row.group,
journals: new Set<string>(),
};
entry.journals.add(row.key);
byGroup.set(row.group.id, entry);
}
try {
let done = 0;
status = `Clearing ${selected.length} transactions`;
await Promise.all(
[...byGroup.values()].map(async ({ group, journals }) => {
await clear(group, journals);
done += 1;
status = `Cleared ${done} of ${byGroup.size}`;
})
);
if (correcting) {
status = "Booking the correction";
await api("/v1/transactions", {
body: correction(reconciliationId ?? ""),
method: "POST",
});
}
status = "Reconciliation stored";
await invalidate();
await goto(showUrl(id));
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
status = "";
await list.refetch();
} finally {
busy = false;
}
}
function rowClass(row: Row) {
const i = open.indexOf(row);
if (i >= 0 && nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
if (nav.isSelected(row)) {
return "bg-row-selected";
}
return row.inRange ? "hover:bg-row-hover" : "text-cladd-fg-softer";
}
</script>
{#snippet marker(
text: string
)}
<tr class="h-7 border-cladd-outline border-t bg-cladd-surface-cut">
<td class="px-3 font-semibold text-[12px] text-cladd-fg-soft" colspan="6">
{text}
</td>
</tr>
{/snippet}
{#snippet line(
row: Row
)}
{@const i = open.indexOf(row)}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(row)}"
id={i >= 0 ? nav.rowId(i) : undefined}
onclick={(event) => i >= 0 && nav.click(i, event)}
>
<td class="w-9 px-3">
{#if row.cleared}
<Check aria-label="Already reconciled" class="size-3.5 text-income" />
{:else if row.inRange}
<input
aria-label="Reconcile {row.split.description}"
checked={nav.isSelected(row)}
class="size-4 accent-[var(--ff-brand)]"
onchange={() => nav.toggle(row)}
onclick={(event) => event.stopPropagation()}
type="checkbox"
>
{/if}
</td>
<td class="num w-28 whitespace-nowrap px-3 text-cladd-fg-soft">
{formatDate(row.date)}
</td>
<td class="max-w-0 truncate px-3">
<a class="hover:underline" href="/transactions/show/{row.group.id}"
>{row.split.description}</a
>
</td>
<td class="whitespace-nowrap px-3 text-right">
<Amount amount={row.amount} {...cur} />
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.source_name}
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.destination_name}
</td>
</tr>
{/snippet}
<PageHeader
crumbs={[
{ href: indexUrl("asset"), label: TYPE_TITLE.asset },
{ href: showUrl(id), label: a?.name ?? "" },
{ label: "Reconcile" },
]}
title="Reconcile {a?.name ?? "account"}"
/>
{#if a && a.type !== "asset"}
<p class="text-expense">
You can only reconcile asset accounts.
<a class="text-brand underline" href={showUrl(id)}>Back to {a.name}</a>
</p>
{:else}
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_25rem]">
<Box body="flex flex-col p-0" title="Reconciliation range">
<table class="w-full text-[13px]">
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8 text-[12px] text-cladd-fg-soft">
<th class="px-3 font-semibold" scope="col"></th>
<th class="px-3 font-semibold" scope="col">Date</th>
<th class="px-3 text-right font-semibold" scope="col">Balance</th>
</tr>
</thead>
<tbody>
<tr class="h-11">
<th class="px-3 text-left font-semibold" scope="row">
Start balance
</th>
<td class="px-3">
<DateInput aria-label="Start date" bind:value={startInput} />
</td>
<td class="px-3">
<input
aria-label="Start balance"
class="{INPUT} num text-right"
inputmode="decimal"
oninput={(event) => {
startTouched = true;
startText = event.currentTarget.value;
}}
value={startTouched
? startText
: (before.data?.toFixed(cur.decimals) ?? "")}
>
</td>
</tr>
<tr class="h-11 border-cladd-outline/60 border-t">
<th class="px-3 text-left font-semibold" scope="row">
End balance
</th>
<td class="px-3">
<DateInput aria-label="End date" bind:value={endInput} />
</td>
<td class="px-3">
<input
aria-label="End balance"
class="{INPUT} num text-right"
inputmode="decimal"
oninput={(event) => {
endTouched = true;
endText = event.currentTarget.value;
}}
value={endTouched
? endText
: (after.data?.toFixed(cur.decimals) ?? "")}
>
</td>
</tr>
</tbody>
</table>
{#if startInput !== start || endInput !== end}
<div
class="flex items-center gap-3 border-cladd-outline border-t px-3 py-2"
>
<Button onclick={() => setDates(startInput, endInput)} size="sm"
>Update selection</Button
>
<span class="text-[12px] text-warn"
>Changing the range clears the ticks.</span
>
</div>
{/if}
<p
class="mt-auto border-cladd-outline border-t px-3 py-2 text-[12px] text-cladd-fg-soft"
>
Match the dates and balances to your bank statement, then tick the
transactions that appear on it.
<span class="kbd">x</span>
ticks the row under the cursor,
<span class="kbd">*</span>
ticks all.
</p>
</Box>
<Box body="flex flex-col gap-3 p-3" title="Reconciliation options">
<div class="flex items-baseline justify-between gap-2">
<span class="text-cladd-fg-soft">Difference</span>
<span
class="num font-semibold text-[20px] {difference === 0
? "text-income"
: "text-expense"}"
>{fmt(difference)}</span
>
</div>
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[12px]">
<dt class="text-cladd-fg-soft">Submitted start balance</dt>
<dd class="num text-right">{fmt(startBalance)}</dd>
<dt class="text-cladd-fg-soft">
Selected transactions ({selected.length})
</dt>
<dd class="num text-right">{fmt(selectedSum)}</dd>
<dt class="text-cladd-fg-soft">
Already cleared transactions ({cleared.length})
</dt>
<dd class="num text-right">{fmt(clearedSum)}</dd>
<dt class="text-cladd-fg-soft">Sum of reconciliation</dt>
<dd class="num text-right">{fmt(sum)}</dd>
<dt class="text-cladd-fg-soft">Submitted end balance</dt>
<dd class="num text-right">{fmt(endBalance)}</dd>
</dl>
{#if difference !== 0}
<p class="text-[12px] text-cladd-fg-soft">
Your Firefly III ledger has {difference > 0 ? "more" : "less"} money
in it than your bank says.
</p>
<fieldset class="m-0 flex flex-col gap-1.5 border-0 p-0 text-[12px]">
<legend class="sr-only">What to do with the difference</legend>
<label class="flex items-start gap-2">
<input
class="mt-0.5 accent-[var(--ff-brand)]"
name="correct"
type="radio"
value={false}
bind:group={correct}
>
Clear the selected transactions, but do not correct.
</label>
<label
class="flex items-start gap-2 {canCorrect
? ""
: "text-cladd-fg-softer"}"
>
<input
class="mt-0.5 accent-[var(--ff-brand)]"
disabled={!canCorrect}
name="correct"
type="radio"
value={true}
bind:group={correct}
>
Clear the selected transactions, and create a correction
{difference > 0 ? "removing" : "adding"}
{fmt(Math.abs(difference))}
{difference > 0 ? "from" : "to"}
this asset account.
<span class="kbd">c</span>
</label>
{#if !canCorrect && reconciliationAccounts.isSuccess}
<p class="text-cladd-fg-soft">
The first correction for this account has to be booked in
<a
class="text-brand underline"
data-sveltekit-reload
href="{page.url.pathname}?ffui=old"
>Firefly's own reconcile page</a
>: through the API, Firefly III cannot create the account that
holds corrections.
</p>
{/if}
</fieldset>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Button
disabled={busy || !ready || (selected.length === 0 && !correcting)}
onclick={store}
type="button"
>Store reconciliation</Button
>
<span class="flex gap-1">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
</div>
{#if status}
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
{/if}
{#if failure}
<p class="text-[12px] text-expense" role="alert">{failure}</p>
{/if}
</Box>
</div>
<Box body="p-0" class="mt-3" title="Transactions">
{#snippet tools()}
<span class="num text-[12px] text-cladd-fg-soft"
>{open.length}
to check, {cleared.length} already cleared</span
>
{/snippet}
{#if list.isPending}
<div class="h-40"></div>
{:else if list.isError}
<p class="p-3 text-expense">{list.error.message}</p>
{:else}
<table class="w-full table-fixed border-collapse text-[13px]">
<colgroup>
<col class="w-9">
<col class="w-28">
<col>
<col class="w-36">
<col class="hidden md:table-column md:w-48">
<col class="hidden md:table-column md:w-48">
</colgroup>
<tbody>
{#each later as row (row.key)}
{@render line(row)}
{/each}
{@render marker(`End of reconcile period: ${formatDate(end)}`)}
{#each inside as row (row.key)}
{@render line(row)}
{/each}
{#if inside.length === 0}
<tr>
<td class="px-3 py-4 text-center text-cladd-fg-soft" colspan="6">
No transactions in this range.
</td>
</tr>
{/if}
{@render marker(`Start of reconcile period: ${formatDate(start)}`)}
{#each earlier as row (row.key)}
{@render line(row)}
{/each}
</tbody>
</table>
{/if}
</Box>
{/if}
+100
View File
@@ -0,0 +1,100 @@
import type { Account, AccountType, Item } from "#lib/api/types.js";
import { isSpaPath, oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
export const TYPE_TITLE: Record<AccountType, string> = {
asset: "Asset accounts",
expense: "Expense accounts",
liabilities: "Liabilities",
revenue: "Revenue accounts",
};
export const TYPE_ONE: Record<AccountType, string> = {
asset: "asset account",
expense: "expense account",
liabilities: "liability",
revenue: "revenue account",
};
export const ROLE_LABEL: Record<string, string> = {
cashWalletAsset: "Cash wallet",
ccAsset: "Credit card",
defaultAsset: "Default asset account",
savingAsset: "Savings account",
sharedAsset: "Shared asset account",
};
export const LIABILITY_LABEL: Record<string, string> = {
debt: "Debt",
loan: "Loan",
mortgage: "Mortgage",
};
export const DIRECTION_LABEL: Record<string, string> = {
credit: "Owed to me",
debit: "I owe",
};
export const PERIOD_LABEL: Record<string, string> = {
daily: "per day",
"half-year": "per half year",
monthly: "per month",
quarterly: "per quarter",
weekly: "per week",
yearly: "per year",
};
const ACCOUNT_TYPES = new Set<string>([
"asset",
"expense",
"revenue",
"liabilities",
]);
export function typeOf(account: Account): AccountType {
const type = String(account.type ?? "asset");
if (ACCOUNT_TYPES.has(type)) {
return type as AccountType;
}
return ["loan", "debt", "mortgage", "liability"].includes(type)
? "liabilities"
: "asset";
}
export const showUrl = (id: string) => `/accounts/show/${id}`;
export const editUrl = (id: string) => `/accounts/edit/${id}`;
export const reconcileUrl = (id: string) => `/accounts/reconcile/${id}/index`;
export const indexUrl = (type: AccountType) => `/accounts/${type}`;
export function createUrl(
type: "withdrawal" | "deposit" | "transfer",
id: string
): string {
const side = type === "deposit" ? "destination" : "source";
return `/transactions/create/${type}?${side}=${id}`;
}
export function go(href: string) {
if (isSpaPath(href.split("?")[0])) {
goto(href);
} else {
location.assign(href);
}
}
export function openOld(href: string) {
location.assign(oldUiUrl(new URL(href, location.origin).href));
}
export function formatIban(account: Account): string {
if (account.iban) {
return account.iban
.replace(/\s+/g, "")
.replace(/(.{4})/g, "$1 ")
.trim();
}
return account.account_number ?? "";
}
export const canReconcile = (item: Item<Account>) =>
typeOf(item.attributes) === "asset";
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { compactMoney, formatNumber } from "#lib/format.js";
import BarRows from "./BarRows.svelte";
import Columns from "./Columns.svelte";
import type { Bar, StackRow } from "./chart.js";
let {
bars,
orientation = "vertical",
color = "var(--ff-series-1)",
name = "Amount",
height = 240,
format = (value: number) => formatNumber(value),
axisFormat = (value: number) => compactMoney(value),
label = "Bar chart",
labelWidth,
active = $bindable(null),
onselect,
}: {
bars: Bar[];
orientation?: "vertical" | "horizontal";
color?: string;
name?: string;
height?: number;
format?: (value: number) => string;
axisFormat?: (value: number) => string;
label?: string;
labelWidth?: string;
active?: string | null;
onselect?: (bar: Bar) => void;
} = $props();
const keys = $derived([{ color, key: "value", label: name }]);
const rows = $derived<StackRow[]>(
bars.map((bar) => ({
color: bar.color,
format: bar.format,
href: bar.href,
key: bar.key,
label: bar.label,
note: bar.note,
values: { value: bar.value },
}))
);
const pick = $derived(
onselect
? (row: StackRow) => {
const bar = bars.find((b) => b.key === row.key);
if (bar) {
onselect(bar);
}
}
: undefined
);
</script>
{#if orientation === "horizontal"}
<BarRows {format} {keys} {label} {labelWidth} onselect={pick} {rows} />
{:else}
<Columns
{axisFormat}
{format}
{height}
{keys}
{label}
onselect={pick}
{rows}
bind:active
/>
{/if}
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { formatNumber } from "#lib/format.js";
import {
type Segment,
type StackKey,
type StackRow,
stack,
} from "./chart.js";
const GAP = 2;
const RADIUS = 4;
let {
keys,
rows,
format = (value: number) => formatNumber(value),
label = "Bar chart",
labelWidth = "minmax(6rem, 14rem)",
onselect,
}: {
keys: StackKey[];
rows: StackRow[];
format?: (value: number) => string;
label?: string;
labelWidth?: string;
onselect?: (row: StackRow) => void;
} = $props();
let list = $state<HTMLElement>();
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
const lo = $derived(Math.min(0, ...stacks.map((s) => s.negative)));
const hi = $derived(Math.max(0, ...stacks.map((s) => s.positive)));
const span = $derived(hi - lo || 1);
const pos = (value: number) => ((value - lo) / span) * 100;
const total = (s: (typeof stacks)[number]) => s.positive + s.negative;
function box({ from, to, outer }: Segment, first: boolean) {
const up = to > from;
const start = pos(Math.min(from, to));
const size = (Math.abs(to - from) / span) * 100;
const inset = first ? 0 : GAP;
let radius = "0";
if (outer) {
radius = up
? `0 ${RADIUS}px ${RADIUS}px 0`
: `${RADIUS}px 0 0 ${RADIUS}px`;
}
return {
left: up ? `calc(${start}% + ${inset}px)` : `${start}%`,
radius,
width: `max(1px, calc(${size}% - ${inset}px))`,
};
}
function onkeydown(event: KeyboardEvent) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
const links = [
...(list?.querySelectorAll<HTMLElement>("[data-bar-link]") ?? []),
];
const index = links.indexOf(document.activeElement as HTMLElement);
if (index < 0) {
return;
}
event.preventDefault();
const next = event.key === "ArrowDown" ? index + 1 : index - 1;
links[Math.max(0, Math.min(links.length - 1, next))]?.focus();
}
</script>
<div class="@container">
<ul
aria-label={label}
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-[12px] @min-[30rem]:grid-cols-(--cols)"
bind:this={list}
style:--cols="{labelWidth} minmax(4rem, 1fr) auto"
>
{#each stacks as s (s.row.key)}
<li
class="col-span-2 grid grid-cols-subgrid items-center gap-y-1 rounded-sm px-1 py-1.5 focus-within:bg-row-cursor hover:bg-row-hover @min-[30rem]:col-span-3 @min-[30rem]:h-7 @min-[30rem]:py-0"
>
{#if s.row.href}
<a
class="col-start-1 row-start-1 truncate text-cladd-fg hover:underline focus-visible:outline-none"
data-bar-link
href={s.row.href}
{onkeydown}
title={s.row.label}
>{s.row.label}</a
>
{:else if onselect}
<button
class="col-start-1 row-start-1 truncate text-left text-cladd-fg hover:underline focus-visible:outline-none"
data-bar-link
onclick={() => onselect(s.row)}
{onkeydown}
title={s.row.label}
type="button"
>
{s.row.label}
</button>
{:else}
<span
class="col-start-1 row-start-1 truncate text-cladd-fg"
title={s.row.label}
>{s.row.label}</span
>
{/if}
<div
class="relative col-span-2 row-start-2 h-3 @min-[30rem]:col-span-1 @min-[30rem]:col-start-2 @min-[30rem]:row-start-1"
>
{#if lo < 0}
<span
class="absolute -inset-y-2 w-px bg-cladd-outline"
style:left="{pos(0)}%"
></span>
{/if}
{#each s.segments as seg, j (seg.key)}
{@const b = box(
seg,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)}
<span
class="absolute inset-y-0"
title="{seg.label}: {(s.row.format ?? format)(seg.value)}"
style:background={seg.color}
style:border-radius={b.radius}
style:left={b.left}
style:width={b.width}
></span>
{/each}
</div>
<span
class="num col-start-2 row-start-1 text-right text-cladd-fg @min-[30rem]:col-start-3"
>{s.row.note ?? (s.row.format ?? format)(total(s))}</span
>
</li>
{/each}
</ul>
</div>
+302
View File
@@ -0,0 +1,302 @@
<script lang="ts">
import { scaleBand, scaleLinear } from "d3-scale";
import { compactMoney, formatNumber } from "#lib/format.js";
import { goto } from "$app/navigation";
import {
crisp,
type LegendItem,
moveIndex,
roundedBar,
type StackKey,
type StackRow,
stack,
textWidth,
} from "./chart.js";
import Legend from "./Legend.svelte";
const TOP = 14;
const RIGHT = 8;
const GAP_Y = 44;
const BAR_MAX = 24;
const GAP = 2;
const FLAT_BOTTOM = 22;
const TILTED_BOTTOM = 70;
const TILTED_CHARS = 16;
const TILTED_CHAR_WIDTH = 4.9;
const LABEL_GAP = 13;
let {
keys,
rows,
height = 240,
format = (value: number) => formatNumber(value),
axisFormat = (value: number) => compactMoney(value),
label = "Bar chart",
active = $bindable(null),
onselect,
}: {
keys: StackKey[];
rows: StackRow[];
height?: number;
format?: (value: number) => string;
axisFormat?: (value: number) => string;
label?: string;
active?: string | null;
onselect?: (row: StackRow) => void;
} = $props();
let width = $state(0);
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
const yBase = $derived(
scaleLinear()
.domain([
Math.min(0, ...stacks.map((s) => s.negative)),
Math.max(0, ...stacks.map((s) => s.positive)) || 1,
])
.nice(yCount)
);
const yTicks = $derived(yBase.ticks(yCount));
const left = $derived(
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
);
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
const tilted = $derived(
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
);
const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
const y = $derived(yBase.copy().range([height - bottom, TOP]));
const x = $derived(
scaleBand<string>()
.domain(rows.map((r) => r.key))
.range([left, Math.max(left + 1, width - RIGHT)])
.paddingInner(0.3)
.paddingOuter(0.15)
);
const barWidth = $derived(Math.max(2, Math.min(BAR_MAX, x.bandwidth())));
const zeroY = $derived(y(0));
const capLabels = $derived(
keys.length === 1 &&
stacks.every(
(s) =>
textWidth(fmt(s.row, s.positive + s.negative), 6.8) <= x.step() - 6
)
);
const activeIndex = $derived(
active ? rows.findIndex((r) => r.key === active) : -1
);
const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
const legend = $derived<LegendItem[]>(
readRow
? keys.map((k) => ({
color: (keys.length === 1 && readRow.color) || k.color,
key: k.key,
label: keys.length === 1 ? readRow.label : k.label,
shape: "rect",
value: fmt(readRow, readRow.values[k.key] ?? 0),
}))
: []
);
const valueText = $derived(
readRow
? `${readRow.label}: ${legend.map((l) => `${keys.length > 1 ? `${l.label} ` : ""}${l.value}`).join(", ")}`
: ""
);
function short(text: string, chars: number) {
return text.length > chars
? `${text.slice(0, Math.max(1, chars - 1))}…`
: text;
}
function select(row: StackRow | undefined) {
if (!row) {
return;
}
if (onselect) {
onselect(row);
} else if (row.href) {
goto(row.href);
}
}
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
function pick(event: PointerEvent) {
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
const offset = event.clientX - box.left - x.range()[0];
const index = Math.floor(
offset / x.step() - x.paddingOuter() + x.paddingInner() / 2
);
active = rows[Math.max(0, Math.min(rows.length - 1, index))]?.key ?? null;
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
select(rows[activeIndex]);
return;
}
if (event.key === "Escape" && active) {
event.preventDefault();
active = null;
return;
}
const next = moveIndex(event.key, activeIndex, rows.length);
if (next !== null) {
event.preventDefault();
active = rows[next].key;
}
}
function segmentPath(
cx: number,
from: number,
to: number,
outer: boolean,
first: boolean
) {
const up = to > from;
let top = y(Math.max(from, to));
let bot = y(Math.min(from, to));
if (!first) {
if (up) {
bot -= GAP;
} else {
top += GAP;
}
}
const h = Math.max(0, bot - top);
const xLeft = cx - barWidth / 2;
if (!outer) {
return `M${xLeft},${top}h${barWidth}v${h}h${-barWidth}Z`;
}
return roundedBar(xLeft, top, barWidth, h, up ? "top" : "bottom");
}
</script>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
{#if keys.length > 1 && readRow}
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
>{readRow.label}</span
>
{/if}
<Legend class="min-w-0 flex-1" items={legend} />
</div>
<div
aria-label={label}
aria-valuemax={Math.max(0, rows.length - 1)}
aria-valuemin={0}
aria-valuenow={Math.max(0, activeIndex)}
aria-valuetext={valueText}
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
onblur={() => (active = null)}
onclick={() => select(rows[activeIndex])}
onfocus={() => (active ??= rows[0]?.key ?? null)}
{onkeydown}
onpointerleave={() => (active = null)}
onpointermove={pick}
role="slider"
tabindex="0"
style:height="{height}px"
bind:clientWidth={width}
>
{#if width > 0 && rows.length > 0}
<svg aria-hidden="true" class="block" {height} {width}>
{#each yTicks as tick (tick)}
<line
stroke="var(--cladd-outline)"
x1={left}
x2={width - RIGHT}
y1={crisp(y(tick))}
y2={crisp(y(tick))}
/>
<text
class="num"
dominant-baseline="middle"
fill="var(--cladd-fg-soft)"
font-size="11"
text-anchor="end"
x={left - 8}
y={y(tick)}
>
{axisFormat(tick)}
</text>
{/each}
<line
stroke="var(--cladd-fg-softest)"
x1={left}
x2={width - RIGHT}
y1={crisp(zeroY)}
y2={crisp(zeroY)}
/>
{#each stacks as s, i (s.row.key)}
{@const cx = (x(s.row.key) ?? 0) + x.bandwidth() / 2}
{@const dim = activeIndex >= 0 && activeIndex !== i}
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
{#each s.segments as seg, j (seg.key)}
<path
d={segmentPath(
cx,
seg.from,
seg.to,
seg.outer,
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
)}
style:fill={seg.color}
/>
{/each}
</g>
{#if capLabels && s.segments[0]}
{@const text = fmt(s.row, s.segments[0].value)}
{#if text}
<text
class="num"
fill="var(--cladd-fg-soft)"
font-size="11"
text-anchor="middle"
x={cx}
y={s.segments[0].value > 0
? y(s.positive) - 5
: y(s.negative) + 13}
>
{text}
</text>
{/if}
{/if}
{#if tilted && i % labelEvery === 0}
<text
fill={activeIndex === i
? "var(--cladd-fg)"
: "var(--cladd-fg-soft)"}
font-size="11"
text-anchor="end"
transform="translate({cx + 3},{height - bottom + 12}) rotate(-40)"
>
{short(
s.row.label,
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
)}
</text>
{:else if !tilted}
<text
fill={activeIndex === i
? "var(--cladd-fg)"
: "var(--cladd-fg-soft)"}
font-size="11"
text-anchor="middle"
x={cx}
y={height - 6}
>
{short(s.row.label, Math.floor(x.step() / 6.4))}
</text>
{/if}
{/each}
</svg>
{/if}
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import type { LegendItem } from "./chart.js";
let {
items,
ontoggle,
widths = {},
class: className = "",
}: {
items: LegendItem[];
ontoggle?: (key: string) => void;
widths?: Record<string, number>;
class?: string;
} = $props();
</script>
{#snippet content(
item: LegendItem
)}
<span
aria-hidden="true"
class="shrink-0 {item.shape === "rect"
? "size-2.5 rounded-[3px]"
: "h-0.5 w-3 rounded-full"}"
style:background={item.color}
></span>
{#if item.value !== undefined}
<span
class="num font-semibold text-cladd-fg"
style:min-width={widths[item.key] ? `${widths[item.key]}ch` : undefined}
>{item.value}</span
>
{/if}
<span class="truncate text-cladd-fg-soft">{item.label}</span>
{/snippet}
<ul class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] {className}">
{#each items as item (item.key)}
<li class="min-w-0 max-w-full">
{#if ontoggle}
<button
aria-pressed={!item.hidden}
class="flex min-w-0 max-w-full items-center gap-1.5 rounded-sm hover:text-cladd-fg {item.hidden
? "opacity-40"
: ""}"
onclick={() => ontoggle(item.key)}
title={item.hidden ? `Show ${item.label}` : `Hide ${item.label}`}
type="button"
>
{@render content(item)}
</button>
{:else}
<span class="flex min-w-0 items-center gap-1.5"
>{@render content(item)}</span
>
{/if}
</li>
{/each}
</ul>
+363
View File
@@ -0,0 +1,363 @@
<script lang="ts">
import { bisector, extent } from "d3-array";
import { scaleLinear, scaleUtc } from "d3-scale";
import {
area as areaShape,
curveMonotoneX,
line as lineShape,
} from "d3-shape";
import { compactMoney, formatDate, formatNumber } from "#lib/format.js";
import {
crisp,
type LegendItem,
type LinePoint,
type LineSeries,
moveIndex,
seriesColor,
textWidth,
utc,
} from "./chart.js";
import Legend from "./Legend.svelte";
const TOP = 16;
const BOTTOM = 22;
const RIGHT = 14;
const GAP_Y = 44;
const GAP_X = 96;
const MIN_TICK_GAP = 70;
const DAY = 86_400_000;
let {
series,
height = 240,
area = false,
marker,
markerLabel = "Today",
zero = false,
cursor = $bindable(null),
axisFormat = (value: number) => compactMoney(value),
xFormat = (value: string) => formatDate(value, "short"),
label = "Line chart",
toggle = true,
}: {
series: LineSeries[];
height?: number;
area?: boolean;
marker?: string;
markerLabel?: string;
zero?: boolean;
cursor?: string | null;
axisFormat?: (value: number) => string;
xFormat?: (x: string) => string;
label?: string;
toggle?: boolean;
} = $props();
let width = $state(0);
let hidden = $state(new Set<string>());
const colored = $derived(
series.map((s, i) => ({ ...s, color: s.color ?? seriesColor(i) }))
);
const shown = $derived(colored.filter((s) => !hidden.has(s.key)));
const xs = $derived(
[...new Set(colored.flatMap((s) => s.points.map((p) => p.x)))].sort()
);
const times = $derived(xs.map((day) => utc(day).getTime()));
const lookup = $derived(
new Map(
colored.map((s) => [s.key, new Map(s.points.map((p) => [p.x, p.y]))])
)
);
const yDomain = $derived.by(() => {
const ys = shown.flatMap((s) => s.points.map((p) => p.y));
if (zero) {
ys.push(0);
}
const [lo = 0, hi = 0] = extent(ys);
if (lo === hi) {
const pad = Math.abs(lo) * 0.1 || 1;
return [lo - pad, hi + pad];
}
return [lo, hi];
});
const yCount = $derived(
Math.max(2, Math.floor((height - TOP - BOTTOM) / GAP_Y))
);
const y = $derived(
scaleLinear()
.domain(yDomain)
.nice(yCount)
.range([height - BOTTOM, TOP])
);
const yTicks = $derived(y.ticks(yCount));
const left = $derived(
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
);
const xDomain = $derived.by(() => {
const first = times[0] ?? 0;
const last = times.at(-1) ?? first;
return first === last ? [first - DAY, last + DAY] : [first, last];
});
const x = $derived(
scaleUtc()
.domain(xDomain)
.range([left, Math.max(left + 1, width - RIGHT)])
);
const xTicks = $derived.by(() => {
const count = Math.max(3, Math.floor((width - left - RIGHT) / GAP_X));
const candidates =
xs.length <= count
? xs
: [...new Set(x.ticks(count).map((d) => d.toISOString().slice(0, 10)))];
const kept: string[] = [];
let last = Number.NEGATIVE_INFINITY;
for (const tick of candidates) {
const px = x(utc(tick));
if (px - last >= MIN_TICK_GAP) {
kept.push(tick);
last = px;
}
}
return kept;
});
const baseline = $derived.by(() => {
const [lo, hi] = y.domain();
return y(Math.max(lo, Math.min(0, hi)));
});
const zeroInside = $derived(y.domain()[0] < 0 && y.domain()[1] > 0);
const at = (p: LinePoint) => x(utc(p.x));
const lineGen = $derived(
lineShape<LinePoint>()
.x(at)
.y((p) => y(p.y))
.curve(curveMonotoneX)
);
const areaGen = $derived(
areaShape<LinePoint>()
.x(at)
.y0(baseline)
.y1((p) => y(p.y))
.curve(curveMonotoneX)
);
const markerIndex = $derived(marker ? xs.indexOf(marker) : -1);
const restIndex = $derived(markerIndex >= 0 ? markerIndex : xs.length - 1);
const index = $derived(cursor ? xs.indexOf(cursor) : -1);
const readIndex = $derived(index >= 0 ? index : restIndex);
const readX = $derived(xs[readIndex]);
const readLabel = $derived.by(() => {
if (!readX) {
return "";
}
if (index < 0 && markerIndex >= 0) {
return markerLabel;
}
return formatDate(readX, "day");
});
const fmt = (s: LineSeries, value: number) =>
s.format ? s.format(value) : formatNumber(value);
const widths = $derived(
Object.fromEntries(
colored.map((s) => [
s.key,
Math.max(0, ...s.points.map((p) => fmt(s, p.y).length)),
])
)
);
const legend = $derived<LegendItem[]>(
colored.map((s) => {
const value = readX ? lookup.get(s.key)?.get(readX) : undefined;
return {
color: s.color,
hidden: hidden.has(s.key),
key: s.key,
label: s.label,
shape: "line",
value: value === undefined ? "" : fmt(s, value),
};
})
);
const valueText = $derived(
[readLabel, ...legend.map((l) => `${l.label} ${l.value}`)].join(", ")
);
function flip(key: string) {
const next = new Set(hidden);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
hidden = next;
}
function pick(event: PointerEvent) {
if (xs.length === 0) {
return;
}
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
const t = x.invert(event.clientX - box.left).getTime();
cursor = xs[bisector((v: number) => v).center(times, t)] ?? null;
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Escape" && cursor) {
event.preventDefault();
cursor = null;
return;
}
const next = moveIndex(event.key, readIndex, xs.length);
if (next === null) {
return;
}
event.preventDefault();
cursor = xs[next];
}
function xAnchor(tick: string) {
const px = x(utc(tick));
if (px - left < 24) {
return "start";
}
return width - RIGHT - px < 24 ? "end" : "middle";
}
</script>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="num w-[11ch] shrink-0 text-[12px] text-cladd-fg-soft"
>{readLabel}</span
>
<Legend
class="min-w-0 flex-1"
items={legend}
ontoggle={toggle && colored.length > 1 ? flip : undefined}
{widths}
/>
</div>
<div
aria-label={label}
aria-valuemax={Math.max(0, xs.length - 1)}
aria-valuemin={0}
aria-valuenow={readIndex}
aria-valuetext={valueText}
class="relative touch-pan-y rounded-sm"
onblur={() => (cursor = null)}
onfocus={() => (cursor ??= xs[restIndex] ?? null)}
{onkeydown}
onpointerleave={() => (cursor = null)}
onpointermove={pick}
role="slider"
tabindex="0"
style:height="{height}px"
bind:clientWidth={width}
>
{#if width > 0 && xs.length > 0}
<svg aria-hidden="true" class="block" {height} {width}>
{#each yTicks as tick (tick)}
<line
stroke="var(--cladd-outline)"
x1={left}
x2={width - RIGHT}
y1={crisp(y(tick))}
y2={crisp(y(tick))}
/>
<text
class="num"
dominant-baseline="middle"
fill="var(--cladd-fg-soft)"
font-size="11"
text-anchor="end"
x={left - 8}
y={y(tick)}
>
{axisFormat(tick)}
</text>
{/each}
{#if zeroInside}
<line
stroke="var(--cladd-fg-softest)"
x1={left}
x2={width - RIGHT}
y1={crisp(y(0))}
y2={crisp(y(0))}
/>
{/if}
{#each xTicks as tick (tick)}
<text
fill="var(--cladd-fg-soft)"
font-size="11"
text-anchor={xAnchor(tick)}
x={x(utc(tick))}
y={height - 6}
>
{xFormat(tick)}
</text>
{/each}
{#if markerIndex >= 0}
{@const mx = crisp(x(utc(xs[markerIndex])))}
<line
stroke="var(--cladd-fg-softer)"
x1={mx}
x2={mx}
y1={TOP - 2}
y2={height - BOTTOM}
/>
<text
fill="var(--cladd-fg-soft)"
font-size="10"
text-anchor={mx > width - 60 ? "end" : "middle"}
x={mx}
y={TOP - 5}
>
{markerLabel}
</text>
{/if}
{#each shown as s (s.key)}
{#if area || s.area}
<path
d={areaGen(s.points)}
fill-opacity="0.1"
style:fill={s.color}
/>
{/if}
<path
d={lineGen(s.points)}
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={s.strong ? 2.5 : 2}
style:stroke={s.color}
/>
{/each}
{#if index >= 0}
{@const cx = crisp(x(utc(xs[index])))}
<line
stroke="var(--cladd-fg-soft)"
x1={cx}
x2={cx}
y1={TOP}
y2={height - BOTTOM}
/>
{#each shown as s (s.key)}
{@const value = lookup.get(s.key)?.get(xs[index])}
{#if value !== undefined}
<circle
cx={x(utc(xs[index]))}
cy={y(value)}
r="4"
stroke="var(--cladd-surface)"
stroke-width="2"
style:fill={s.color}
/>
{/if}
{/each}
{/if}
</svg>
{/if}
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { type MeterSegment, seriesColor, wash } from "./chart.js";
const GAP = 2;
let {
segments,
max,
height = 8,
track,
label,
}: {
segments: MeterSegment[];
max: number;
height?: number;
track?: string;
label?: string;
} = $props();
const total = $derived(
segments.reduce((sum, s) => sum + Math.max(0, s.value), 0)
);
const scale = $derived(Math.max(max, total) || 1);
const parts = $derived(
segments
.filter((s) => s.value > 0)
.map((s, i) => ({
...s,
color: s.color ?? seriesColor(i),
share: (s.value / scale) * 100,
}))
);
const trackColor = $derived(track ?? wash(parts[0]?.color ?? seriesColor(0)));
const title = $derived(
label ??
segments
.filter((s) => s.label)
.map((s) => s.label)
.join(", ")
);
</script>
<div
aria-hidden="true"
class="flex w-full overflow-hidden rounded-full"
{title}
style:background={trackColor}
style:gap="{GAP}px"
style:height="{height}px"
>
{#each parts as part, i (i)}
<span
class="h-full shrink-0 first:rounded-l-full last:rounded-r-full"
style:background={part.color}
style:width="calc({part.share}% - {i === 0 ? 0 : GAP}px)"
></span>
{/each}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { extent } from "d3-array";
import { scaleLinear } from "d3-scale";
import {
area as areaShape,
curveMonotoneX,
line as lineShape,
} from "d3-shape";
let {
values,
color = "var(--ff-series-1)",
width = 96,
height = 24,
marker = -1,
}: {
values: number[];
color?: string;
width?: number;
height?: number;
marker?: number;
} = $props();
const PAD = 3;
const x = $derived(
scaleLinear()
.domain([0, Math.max(1, values.length - 1)])
.range([PAD, width - PAD])
);
const y = $derived.by(() => {
const [lo = 0, hi = 0] = extent(values);
const pad = lo === hi ? Math.abs(lo) * 0.1 || 1 : 0;
return scaleLinear()
.domain([lo - pad, hi + pad])
.range([height - PAD, PAD]);
});
const line = $derived(
lineShape<number>()
.x((_, i) => x(i))
.y((v) => y(v))
.curve(curveMonotoneX)(values) ?? ""
);
const fill = $derived(
areaShape<number>()
.x((_, i) => x(i))
.y0(height - PAD)
.y1((v) => y(v))
.curve(curveMonotoneX)(values) ?? ""
);
const end = $derived(
marker >= 0 && marker < values.length ? marker : values.length - 1
);
</script>
{#if values.length > 1}
<svg aria-hidden="true" class="block shrink-0" {height} {width}>
<path d={fill} fill-opacity="0.1" style:fill={color} />
<path
d={line}
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
style:stroke={color}
/>
<circle
cx={x(end)}
cy={y(values[end])}
r="2.5"
stroke="var(--cladd-surface)"
stroke-width="1.5"
style:fill={color}
/>
</svg>
{/if}
@@ -0,0 +1,51 @@
<script lang="ts">
import { compactMoney, formatNumber } from "#lib/format.js";
import BarRows from "./BarRows.svelte";
import Columns from "./Columns.svelte";
import type { StackKey, StackRow } from "./chart.js";
import Legend from "./Legend.svelte";
let {
keys,
rows,
orientation = "vertical",
height = 240,
format = (value: number) => formatNumber(value),
axisFormat = (value: number) => compactMoney(value),
label = "Stacked bar chart",
labelWidth,
active = $bindable(null),
onselect,
}: {
keys: StackKey[];
rows: StackRow[];
orientation?: "vertical" | "horizontal";
height?: number;
format?: (value: number) => string;
axisFormat?: (value: number) => string;
label?: string;
labelWidth?: string;
active?: string | null;
onselect?: (row: StackRow) => void;
} = $props();
</script>
{#if orientation === "horizontal"}
<div class="flex min-w-0 flex-col gap-2">
{#if keys.length > 1}
<Legend items={keys.map((k) => ({ ...k, shape: "rect" as const }))} />
{/if}
<BarRows {format} {keys} {label} {labelWidth} {onselect} {rows} />
</div>
{:else}
<Columns
{axisFormat}
{format}
{height}
{keys}
{label}
{onselect}
{rows}
bind:active
/>
{/if}
+187
View File
@@ -0,0 +1,187 @@
export interface LinePoint {
x: string;
y: number;
}
export interface LineSeries {
area?: boolean;
color?: string;
format?: (value: number) => string;
key: string;
label: string;
points: LinePoint[];
strong?: boolean;
}
export interface Bar {
color?: string;
format?: (value: number) => string;
href?: string;
key: string;
label: string;
note?: string;
value: number;
}
export interface StackKey {
color: string;
key: string;
label: string;
}
export interface StackRow {
color?: string;
format?: (value: number) => string;
href?: string;
key: string;
label: string;
note?: string;
values: Record<string, number>;
}
export interface MeterSegment {
color?: string;
label?: string;
value: number;
}
export interface LegendItem {
color: string;
hidden?: boolean;
key: string;
label: string;
shape?: "line" | "rect";
value?: string;
}
export type Side = "top" | "bottom" | "left" | "right";
const PALETTE_SIZE = 8;
export const OTHER_COLOR = "var(--cladd-fg-softest)";
export const tones = {
expense: "var(--ff-expense)",
income: "var(--ff-income)",
transfer: "var(--ff-transfer)",
warn: "var(--ff-warn)",
};
export function seriesColor(index: number): string {
return index < PALETTE_SIZE ? `var(--ff-series-${index + 1})` : OTHER_COLOR;
}
export function wash(color: string, amount = 18): string {
return `color-mix(in oklch, ${color} ${amount}%, transparent)`;
}
export function utc(iso: string): Date {
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
export const crisp = (value: number) => Math.round(value) + 0.5;
export function textWidth(text: string, px = 6.6): number {
return text.length * px;
}
export function roundedBar(
x: number,
y: number,
w: number,
h: number,
side: Side,
radius = 4
): string {
const across = side === "top" || side === "bottom" ? w : h;
const along = side === "top" || side === "bottom" ? h : w;
const r = Math.max(0, Math.min(radius, across / 2, along));
const arc = (tx: number, ty: number) => `A${r},${r} 0 0 1 ${tx},${ty}`;
const right = x + w;
const bottom = y + h;
switch (side) {
case "top":
return `M${x},${bottom}V${y + r}${arc(x + r, y)}H${right - r}${arc(right, y + r)}V${bottom}Z`;
case "bottom":
return `M${right},${y}V${bottom - r}${arc(right - r, bottom)}H${x + r}${arc(x, bottom - r)}V${y}Z`;
case "right":
return `M${x},${y}H${right - r}${arc(right, y + r)}V${bottom - r}${arc(right - r, bottom)}H${x}Z`;
default:
return `M${right},${bottom}H${x + r}${arc(x, bottom - r)}V${y + r}${arc(x + r, y)}H${right}Z`;
}
}
export interface Segment {
color: string;
from: number;
key: string;
label: string;
outer: boolean;
to: number;
value: number;
}
export function stack(
row: StackRow,
keys: StackKey[]
): { segments: Segment[]; positive: number; negative: number } {
let positive = 0;
let negative = 0;
const segments: Segment[] = [];
for (const key of keys) {
const value = row.values[key.key] ?? 0;
if (value === 0) {
continue;
}
const from = value > 0 ? positive : negative;
const to = from + value;
if (value > 0) {
positive = to;
} else {
negative = to;
}
segments.push({
color: (keys.length === 1 && row.color) || key.color,
from,
key: key.key,
label: key.label,
outer: false,
to,
value,
});
}
const lastUp = segments.findLast((s) => s.value > 0);
const lastDown = segments.findLast((s) => s.value < 0);
for (const s of segments) {
s.outer = s === lastUp || s === lastDown;
}
return { negative, positive, segments };
}
export function moveIndex(
key: string,
index: number,
count: number,
axis: "x" | "y" = "x"
): number | null {
const back = axis === "x" ? "ArrowLeft" : "ArrowUp";
const forward = axis === "x" ? "ArrowRight" : "ArrowDown";
const start = index < 0 ? count : index;
switch (key) {
case back:
return Math.max(0, start - 1);
case forward:
return Math.min(count - 1, index + 1);
case "PageUp":
return Math.max(0, start - 7);
case "PageDown":
return Math.min(count - 1, index + 7);
case "Home":
return 0;
case "End":
return count - 1;
default:
return null;
}
}
@@ -0,0 +1,188 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { DropdownMenu } from "bits-ui";
import { api } from "#lib/api/client.js";
import type {
Account,
Item,
Page,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import {
canReconcile,
createUrl,
editUrl,
go,
openOld,
reconcileUrl,
showUrl,
} from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import { span } from "#lib/components/dashboard/data.js";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { contains } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
groupTitle,
signedAmount,
signedForeign,
} from "#lib/transactions/split.js";
const LIMIT = 10;
const TRIGGER =
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2.5 font-semibold text-[12px]";
let {
account,
trend = [],
marker = -1,
}: { account: Item<Account>; trend?: number[]; marker?: number } = $props();
const a = $derived(account.attributes);
const cur = $derived({
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
});
const list = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<Page<TransactionGroup>>(`/v1/accounts/${account.id}/transactions`, {
query: { ...span(period.start, period.end), limit: LIMIT },
}),
queryKey: ["account-tx", account.id, period.start, period.end, LIMIT],
}));
const rows = $derived(
(list.data?.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter(
(s) =>
(s.source_id === account.id || s.destination_id === account.id) &&
contains(period.range, s.date)
)
.map((split, i) => ({
foreign: signedForeign(split, account.id),
group,
key: `${group.id}-${i}`,
money: signedAmount(split, account.id),
split,
title:
group.attributes.transactions.length > 1
? `${groupTitle(group)}: ${split.description}`
: split.description,
}))
)
.slice(0, LIMIT)
);
const change = $derived(toNumber(a.balance_difference));
const trendColor = $derived(
change < 0 ? "var(--ff-expense)" : "var(--ff-income)"
);
</script>
<Box body="py-1" href={showUrl(account.id)} title={a.name}>
{#snippet tools()}
{#if trend.length > 1}
<Sparkline color={trendColor} {marker} values={trend} width={72} />
{/if}
{#if change !== 0}
<Amount amount={change} signed {...cur} class="text-[12px]" />
{/if}
{/snippet}
<div class="transition-opacity {list.isPlaceholderData ? "opacity-60" : ""}">
{#if list.isPending}
<div class="h-14"></div>
{:else if list.isError}
<p class="px-3 py-2 text-expense">{list.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-3 text-cladd-fg-soft">
No transactions in this period.
</p>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.key)}
<li>
<a
class="flex h-10 items-center gap-2 px-3 text-[12.5px] hover:bg-row-hover sm:h-7"
href="/transactions/show/{row.group.id}"
>
<span class="num w-12 shrink-0 text-cladd-fg-soft"
>{formatDate(row.split.date.slice(0, 10), "short")}</span
>
<span
class="min-w-0 flex-1 truncate text-cladd-fg"
title={row.title}
>{row.title}</span
>
{#if row.foreign}
<span class="num shrink-0 text-[11px] text-cladd-fg-soft"
>({formatMoney(row.foreign)})</span
>
{/if}
<Amount
amount={row.money.amount}
code={row.money.code}
decimals={row.money.decimals}
symbol={row.money.symbol}
tone={row.money.tone}
/>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{#snippet footer()}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="{TRIGGER} cladd-color-green text-cladd-primary"
>
Create <ChevronDown class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="start" class="w-48">
<DropdownMenuItem
onSelect={() => go(createUrl("withdrawal", account.id))}
>New withdrawal</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("deposit", account.id))}
>New deposit</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => go(createUrl("transfer", account.id))}
>New transfer</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="{TRIGGER} ml-auto text-cladd-fg">
<span class="num"
>{formatMoney({ amount: a.current_balance, ...cur })}</span
>
<ChevronDown class="size-3.5" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuItem onSelect={() => go(showUrl(account.id))}
>Show</DropdownMenuItem
>
{#if canReconcile(account)}
<DropdownMenuItem onSelect={() => go(reconcileUrl(account.id))}
>Reconcile</DropdownMenuItem
>
{/if}
<DropdownMenuItem onSelect={() => go(editUrl(account.id))}
>Edit</DropdownMenuItem
>
<DropdownMenuItem onSelect={() => openOld(showUrl(account.id))}
>Open in old UI</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu.Root>
{/snippet}
</Box>
@@ -0,0 +1,190 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import Box from "#lib/components/Box.svelte";
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import { contains, today } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
accountsChartQuery,
accountsQuery,
axis,
byCurrency,
type ChartSet,
type Cur,
hasPrimary,
hrefWith,
money,
seriesPoints,
setCurrency,
setPrimary,
} from "./data.js";
const HEIGHT = 330;
const MIN_PANEL = 130;
const mode = $derived(
page.url.searchParams.get("chart") === "net-worth"
? "net-worth"
: "accounts"
);
const chart = createQuery(() => ({
...accountsChartQuery(period.start, period.end, mode === "net-worth"),
placeholderData: keepPreviousData,
}));
const assets = createQuery(() => ({
...accountsQuery("asset", period.start, period.end),
enabled: mode === "net-worth",
}));
const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end),
enabled: mode === "net-worth",
}));
let cursor = $state<string | null>(null);
const sets = $derived(chart.data ?? []);
const converted = $derived(hasPrimary(sets));
const excluded = $derived(
new Set(
[...(assets.data ?? []), ...(liabilities.data ?? [])]
.filter((a) => a.attributes.include_net_worth === false)
.map((a) => a.attributes.name)
)
);
interface Panel {
cur: Cur;
series: LineSeries[];
}
function sum(group: ChartSet[]): LinePoint[] {
const totals = new Map<string, number>();
for (const set of group) {
for (const p of seriesPoints(set, converted)) {
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
}
}
return [...totals.entries()].map(([x, y]) => ({ x, y }));
}
const panels = $derived.by<Panel[]>(() => {
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
const cur = curOf(group[0]);
const format = money(cur);
if (mode === "net-worth") {
const included = group.filter((s) => !excluded.has(s.label));
return {
cur,
series: [
{
area: true,
format,
key: "net-worth",
label: `Net worth (${cur.code})`,
points: sum(included),
strong: true,
},
],
};
}
return {
cur,
series: group.map((s) => ({
area: group.length === 1,
format,
key: s.label,
label: s.label,
points: seriesPoints(s, converted),
})),
};
});
});
const panelHeight = $derived(
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
);
const marker = $derived(
contains(period.range, today()) ? today() : undefined
);
function setMode(next: string) {
goto(hrefWith(page.url, { chart: next === "accounts" ? null : next }), {
replace: true,
});
}
</script>
<Box href="/accounts/asset" title="Your accounts">
{#snippet tools()}
<fieldset
class="cladd-cut m-0 flex min-w-0 rounded-cladd-sm border-0 p-0.5 text-[12px]"
>
<legend class="sr-only">Chart shows</legend>
{#each [
["accounts", "Accounts"],
["net-worth", "Net worth"],
] as [value, text] (value)}
<button
aria-pressed={mode === value}
class="rounded-[5px] px-2 py-0.5 {mode === value
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => setMode(value)}
type="button"
>
{text}
</button>
{/each}
</fieldset>
{/snippet}
<div
class="flex flex-col gap-5 transition-opacity {chart.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if chart.isPending}
<div style:height="{HEIGHT}px"></div>
{:else if chart.isError}
<p class="text-expense">{chart.error.message}</p>
{:else if panels.length === 0}
<p class="py-10 text-center text-cladd-fg-soft">
No accounts to draw. Pick the accounts for this chart in
<a class="text-brand underline" href="/preferences">Preferences</a>.
</p>
{:else}
{#each panels as panel (panel.cur.code)}
<div class="flex flex-col gap-1">
{#if panels.length > 1}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{panel.cur.code}</span
>
{/if}
<LineChart
axisFormat={axis(panel.cur)}
height={panelHeight}
label="{mode === "net-worth"
? "Net worth"
: "Account balances"} in {panel.cur.code}"
{marker}
series={panel.series}
bind:cursor
/>
</div>
{/each}
{/if}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/accounts/asset"
>View your asset accounts</a
>
{#if converted}
<span class="ml-auto text-cladd-fg-soft"
>Converted to your primary currency</span
>
{/if}
{/snippet}
</Box>
@@ -0,0 +1,121 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import {
type StackKey,
type StackRow,
tones,
wash,
} from "#lib/components/charts/chart.js";
import StackedBars from "#lib/components/charts/StackedBars.svelte";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
byCurrency,
type ChartSet,
money,
setCurrency,
span,
} from "./data.js";
const KEYS: StackKey[] = [
{ color: "var(--ff-series-1)", key: "spent", label: "Spent in budget" },
{
color: wash("var(--ff-series-1)", 24),
key: "left",
label: "Left to spend",
},
{ color: tones.expense, key: "over", label: "Overspent" },
];
const chart = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<ChartSet[]>("/v1/chart/budget/overview", {
query: span(period.start, period.end),
}),
queryKey: ["chart-budgets", period.start, period.end],
}));
const budgets = createQuery(() => lists.budgets());
const ids = $derived(
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
);
const groups = $derived(
byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
const format = money(setCurrency(sets[0]));
const rows: StackRow[] = sets
.map((s) => {
const budgeted = toNumber(s.entries.budgeted);
const spent = Math.abs(toNumber(s.entries.spent));
const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
const id = ids.get(s.label);
return {
format,
href: id ? `/budgets/show/${id}` : undefined,
key: s.label,
label: s.label,
note:
budgeted > 0
? `${format(spent)} of ${format(budgeted)}`
: format(spent),
values: {
left: Math.max(0, toNumber(s.entries.left)),
over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
spent: inside,
},
};
})
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
return { code, keys, limited, rows };
})
);
const visible = $derived(groups.filter((g) => g.rows.length > 0));
</script>
<Box href="/budgets" title="Budgets and spending">
<div
class="flex flex-col gap-4 transition-opacity {chart.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if chart.isPending}
<div class="h-24"></div>
{:else if chart.isError}
<p class="text-expense">{chart.error.message}</p>
{:else if visible.length === 0}
<p class="py-6 text-center text-cladd-fg-soft">
No budgets with a limit or spending in this period. Set an amount for a
budget on the
<a class="text-brand underline" href="/budgets">budgets page</a>.
</p>
{:else}
{#each visible as group (group.code)}
<div class="flex flex-col gap-1">
{#if visible.length > 1 || !group.limited}
<span class="text-[11px] text-cladd-fg-soft">
<span class="font-semibold tracking-wide">{group.code}</span>
{#if !group.limited}
· spent, no budget amount set in {group.code}
{/if}
</span>
{/if}
<StackedBars
keys={group.keys}
label="Budgets in {group.code}"
orientation="horizontal"
rows={group.rows}
/>
</div>
{/each}
{/if}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/budgets">Go to your budgets</a>
{/snippet}
</Box>
@@ -0,0 +1,108 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
type ChartSet,
hasPrimary,
money,
proportional,
setCurrency,
setPrimary,
span,
} from "./data.js";
const NO_CATEGORY = "/categories/list/no-category";
const chart = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<ChartSet[]>("/v1/chart/category/overview", {
query: span(period.start, period.end),
}),
queryKey: ["chart-categories", period.start, period.end],
}));
const categories = createQuery(() => lists.categories());
const ids = $derived(
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
);
const converted = $derived(hasPrimary(chart.data ?? []));
const groups = $derived(
byCurrency(chart.data ?? [], (s) =>
converted ? setPrimary(s).code : s.currency_code
).map(([code, sets]) => {
const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
const bars: Bar[] = sets
.map((s) => {
const entries = (converted ? s.pc_entries : s.entries) as Record<
string,
string
>;
const id = ids.get(s.label);
return {
href: id ? `/categories/show/${id}` : NO_CATEGORY,
key: s.label,
label: s.label,
value: Math.abs(toNumber(entries?.spent)),
};
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars, code, cur };
})
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
</script>
<Box href="/categories" title="Categories">
<div
class="grid gap-x-6 gap-y-5 transition-opacity md:grid-cols-(--cols) {chart.isPlaceholderData
? "opacity-60"
: ""}"
style:--cols={proportional(visible.map((g) => g.bars.length))}
>
{#if chart.isPending}
<div class="h-60"></div>
{:else if chart.isError}
<p class="text-expense">{chart.error.message}</p>
{:else if visible.length === 0}
<p class="py-10 text-center text-cladd-fg-soft">
Nothing spent in this period. Expenses show up here by category.
</p>
{:else}
{#each visible as group (group.code)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>Spent in {group.code}</span
>
{/if}
<BarChart
axisFormat={axis(group.cur)}
bars={group.bars}
color={tones.expense}
format={money(group.cur)}
height={240}
label="Spent per category in {group.code}"
name="Spent"
/>
</div>
{/each}
{/if}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/categories"
>Go to your categories</a
>
{/snippet}
</Box>
@@ -0,0 +1,103 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { accountsQuery, byCurrency, money } from "./data.js";
const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end),
placeholderData: keepPreviousData,
}));
const groups = $derived(
byCurrency(
(liabilities.data ?? []).filter(
(a) =>
a.attributes.liability_type === "debt" &&
toNumber(a.attributes.debt_amount) !== 0
),
(a) => a.attributes.currency_code ?? ""
).map(([code, list]) => {
const first = list[0].attributes;
const format = money({
code,
decimals: first.currency_decimal_places ?? 2,
symbol: first.currency_symbol ?? code,
});
const bars = list
.map((a) => {
const due = Math.abs(toNumber(a.attributes.debt_amount));
const value =
a.attributes.liability_direction === "credit" ? due : -due;
return {
color: value > 0 ? tones.income : tones.expense,
href: showUrl(a.id),
key: a.id,
label: a.attributes.name,
value,
};
})
.sort((x, y) => y.value - x.value);
const owed = bars
.filter((b) => b.value > 0)
.reduce((s, b) => s + b.value, 0);
const owe = bars
.filter((b) => b.value < 0)
.reduce((s, b) => s - b.value, 0);
return { bars, code, format, owe, owed };
})
);
</script>
{#if groups.length > 0}
<Box href="/accounts/liabilities" title="Debts with friends">
<div
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData
? "opacity-60"
: ""}"
>
{#each groups as group (group.code)}
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline gap-3 text-[12px]">
<span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{group.code}</span
>
{#if group.owed > 0}
<span class="text-cladd-fg-soft"
>Owed to me
<span class="num text-income"
>{group.format(group.owed)}</span
></span
>
{/if}
{#if group.owe > 0}
<span class="text-cladd-fg-soft"
>I owe
<span class="num text-expense"
>{group.format(group.owe)}</span
></span
>
{/if}
</div>
<BarChart
bars={group.bars}
format={group.format}
label="Debts with friends in {group.code}"
labelWidth="minmax(5rem, 9rem)"
orientation="horizontal"
/>
</div>
{/each}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/accounts/liabilities"
>See your liabilities</a
>
{/snippet}
</Box>
{/if}
@@ -0,0 +1,266 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
import ChartLine from "@lucide/svelte/icons/chart-line";
import Scale from "@lucide/svelte/icons/scale";
import Wallet from "@lucide/svelte/icons/wallet";
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import type { Component, Snippet } from "svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
import { currentLocale, formatMoney } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
const CURRENCY_SUFFIX = /\s*\(.*\)$/;
interface Delta {
good: boolean;
text: string;
up: boolean;
}
const current = createQuery(() => ({
...summaryQuery(period.start, period.end),
placeholderData: keepPreviousData,
}));
const prevRange = $derived(shiftPeriod(period.range, period.view, -1));
const previous = createQuery(() =>
summaryQuery(prevRange.start, prevRange.end)
);
const now = $derived(parseSummary(current.data));
const before = $derived(parseSummary(previous.data));
const prevName = $derived.by(() => {
const month = periodOf(prevRange.start, "1M");
if (month.start === prevRange.start && month.end === prevRange.end) {
return new Intl.DateTimeFormat(currentLocale(), {
month: "short",
}).format(parse(prevRange.start));
}
return "the period before";
});
const year = $derived(today().slice(0, 4));
const fmt = (e: SummaryEntry, abs = false) =>
formatMoney({
amount: abs ? Math.abs(e.amount) : e.amount,
code: e.code,
decimals: e.decimals,
symbol: e.symbol,
});
function compare(
kind: string,
words: [string, string],
upIsGood = true
): Delta | null {
const a = now[kind]?.[0];
const b = a && before[kind]?.find((e) => e.code === a.code);
if (!(a && b) || a.amount === b.amount) {
return null;
}
const diff = a.amount - b.amount;
return {
good: diff > 0 === upIsGood,
text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
up: diff > 0,
};
}
const balance = $derived(now.balance ?? []);
const earned = $derived(now.earned?.[0]);
const spent = $derived(now.spent?.[0]);
const unpaid = $derived(now["bills-unpaid"] ?? []);
const paid = $derived(now["bills-paid"] ?? []);
const left = $derived(now["left-to-spend"] ?? []);
const worth = $derived(now["net-worth"] ?? []);
const leftState = $derived.by(() => {
if (left.some((e) => e.noBudgets)) {
return "none";
}
return left.some((e) => e.amount > 0) ? "ok" : "over";
});
const leftTitle = $derived(
leftState === "none"
? (left.find((e) => e.noBudgets)?.title.replace(CURRENCY_SUFFIX, "") ??
"Spent")
: "Left to spend"
);
const leftColor = $derived(
{
none: "var(--ff-transfer)",
ok: "var(--ff-income)",
over: "var(--ff-expense)",
}[leftState]
);
const paidShare = $derived.by(() => {
const code = unpaid[0]?.code ?? paid[0]?.code;
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
return { paid: p, total: p + u };
});
</script>
{#snippet values(
list: SummaryEntry[],
abs = false
)}
{#if list.length === 0}
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
0
</div>
{:else}
<div
class="truncate font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
>
{fmt(list[0], abs)}
</div>
{#if list.length > 1}
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
{#each list.slice(1) as e (e.code)}
<span>{fmt(e, abs)}</span>
{/each}
</div>
{/if}
{/if}
{/snippet}
{#snippet change(
delta: Delta | null
)}
{#if delta}
<div
class="hidden items-center gap-1 text-[12px] sm:flex {delta.good
? "text-income"
: "text-expense"}"
>
{#if delta.up}
<ArrowUp class="size-3.5 shrink-0" />
{:else}
<ArrowDown class="size-3.5 shrink-0" />
{/if}
<span class="truncate"
><span class="num">{delta.text}</span>
<span class="text-cladd-fg-soft">than {prevName}</span></span
>
</div>
{/if}
{/snippet}
{#snippet box(
title: string,
href: string,
icon: Component,
color: string,
body: Snippet
)}
{@const Icon = icon}
<section
class="cladd-surface cladd-outline flex min-w-0 gap-3 rounded-cladd-lg p-3 {current.isPlaceholderData
? "opacity-60"
: ""}"
>
<span
class="hidden size-10 shrink-0 place-items-center rounded-cladd-md text-white sm:grid"
style:background={color}
>
<Icon class="size-5" />
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<a
class="truncate text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
{href}
>{title}</a
>
{@render body()}
</div>
</section>
{/snippet}
{#snippet inOut()}
{@render values(balance)}
{#if earned || spent}
<div class="num flex flex-wrap gap-x-2 text-[12px]">
{#if earned}
<span class="text-income">+{fmt(earned, true)}</span>
{/if}
{#if spent}
<span class="text-expense">−{fmt(spent, true)}</span>
{/if}
</div>
{/if}
{@render change(compare("balance", ["more", "less"]))}
{/snippet}
{#snippet bills()}
{@render values(unpaid, true)}
{#if paidShare.total > 0}
<Meter
max={paidShare.total}
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
/>
{/if}
<div class="truncate text-[12px] text-cladd-fg-soft">
Paid:
<span class="num"
>{paid.length ? paid.map((e) => fmt(e, true)).join(", ") : "0"}</span
>
</div>
{/snippet}
{#snippet leftToSpend()}
{@render values(left)}
{#if left[0]?.sub}
<div class="truncate text-[12px] text-cladd-fg-soft">
{leftState === "none" ? "" : "Per day: "}<span class="num"
>{left[0].sub}</span
>
</div>
{/if}
{@render change(compare("left-to-spend", ["more", "less"]))}
{/snippet}
{#snippet netWorth()}
{@render values(worth)}
{@render change(compare("net-worth", ["more", "less"]))}
{/snippet}
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4">
{@render box(
"In + out this period",
reportPath({
accounts: [ALL_ASSETS],
end: period.end,
items: [],
start: period.start,
type: "default",
}),
Scale,
"var(--ff-transfer)",
inOut
)}
{@render box(
"Subscriptions to pay",
"/subscriptions",
CalendarClock,
"var(--ff-series-3)",
bills
)}
{@render box(leftTitle, "/budgets", Wallet, leftColor, leftToSpend)}
{@render box(
"Net worth",
reportPath({
accounts: [ALL_ASSETS],
end: `${year}-12-31`,
items: [],
start: `${year}-01-01`,
type: "default",
}),
ChartLine,
"var(--ff-brand)",
netWorth
)}
</div>
@@ -0,0 +1,144 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte";
import { type Bar, tones } from "#lib/components/charts/chart.js";
import { toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import {
axis,
byCurrency,
currency,
type InsightEntry,
money,
proportional,
span,
} from "./data.js";
const MAX_BARS = 24;
const NARROW_BARS = 8;
let { kind }: { kind: "expense" | "revenue" } = $props();
const path = $derived(
kind === "expense"
? "/v1/insight/expense/expense"
: "/v1/insight/income/revenue"
);
const insight = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
api<InsightEntry[]>(path, {
query: span(period.start, period.end),
}),
queryKey: ["insight", kind, period.start, period.end],
}));
const title = $derived(
kind === "expense" ? "Expense accounts" : "Revenue accounts"
);
const verb = $derived(kind === "expense" ? "Spent" : "Earned");
function fold(bars: Bar[]): Bar[] {
if (bars.length <= MAX_BARS) {
return bars;
}
const rest = bars.slice(MAX_BARS - 1);
return [
...bars.slice(0, MAX_BARS - 1),
{
key: "other",
label: `Other (${rest.length})`,
value: rest.reduce((sum, b) => sum + b.value, 0),
},
];
}
const groups = $derived(
byCurrency(insight.data ?? [], (e) => e.currency_code).map(
([code, list]) => {
const cur = currency(code);
const bars = list
.map((e) => ({
href: e.id ? showUrl(e.id) : undefined,
key: e.id ?? e.name ?? "",
label: e.name ?? "",
value: Math.abs(toNumber(e.difference)),
}))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
return { bars: fold(bars), code, cur };
}
)
);
const visible = $derived(groups.filter((g) => g.bars.length > 0));
const wide = $derived(
visible.filter((g, i) => i === 0 || g.bars.length > NARROW_BARS)
);
const narrow = $derived(visible.filter((g) => !wide.includes(g)));
</script>
{#snippet chart(
group: (typeof groups)[number],
height: number
)}
<div class="flex min-w-0 flex-col gap-1">
{#if visible.length > 1}
<span class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
>{verb}
in {group.code}</span
>
{/if}
<BarChart
axisFormat={axis(group.cur)}
bars={group.bars}
color={kind === "expense" ? tones.expense : tones.income}
format={money(group.cur)}
{height}
label="{verb} per {kind} account in {group.code}"
name={verb}
/>
</div>
{/snippet}
<Box href="/accounts/{kind}" {title}>
<div
class="flex flex-col gap-5 transition-opacity {insight.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if insight.isPending}
<div class="h-60"></div>
{:else if insight.isError}
<p class="text-expense">{insight.error.message}</p>
{:else if visible.length === 0}
<p class="py-8 text-center text-cladd-fg-soft">
{kind === "expense"
? "Nothing spent in this period."
: "Nothing earned in this period."}
</p>
{:else}
{#each [wide, narrow] as set, i (i)}
{#if set.length > 0}
<div
class="grid gap-x-6 gap-y-5 {i === 1
? "md:grid-cols-(--cols)"
: ""}"
style:--cols={proportional(set.map((g) => g.bars.length))}
>
{#each set as group (group.code)}
{@render chart(group, i === 0 ? 240 : 180)}
{/each}
</div>
{/if}
{/each}
{/if}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/accounts/{kind}"
>See your {kind} accounts</a
>
{/snippet}
</Box>
@@ -0,0 +1,70 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import type { PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { formatMoney, percent, toNumber } from "#lib/format.js";
const piggies = createQuery(() => lists.piggyBanks());
const saving = $derived(
(piggies.data ?? [])
.filter((p) => toNumber(p.attributes.current_amount) > 0)
.sort(
(a, b) =>
toNumber(a.attributes.percentage) - toNumber(b.attributes.percentage)
)
);
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
formatMoney({
amount,
code: p.currency_code,
decimals: p.currency_decimal_places,
symbol: p.currency_symbol,
});
</script>
{#if saving.length > 0}
<Box href="/piggy-banks" title="Piggy banks">
<ul class="flex flex-col gap-3">
{#each saving as item (item.id)}
{@const p = item.attributes}
{@const target = toNumber(p.target_amount)}
<li class="flex flex-col gap-1">
<div class="flex items-baseline gap-2 text-[12.5px]">
<a
class="min-w-0 flex-1 truncate font-semibold text-cladd-fg hover:underline"
href="/piggy-banks/show/{item.id}"
>{p.name}</a
>
<span class="num shrink-0 text-[12px] text-cladd-fg-soft">
<span class="text-cladd-fg">{fmt(p, p.current_amount)}</span>
{#if target > 0}
of {fmt(p, p.target_amount)}
{/if}
</span>
</div>
{#if target > 0}
<div class="flex items-center gap-2">
<Meter
max={target}
segments={[{ value: toNumber(p.current_amount) }]}
/>
<span
class="num w-10 shrink-0 text-right text-[11px] text-cladd-fg-soft"
>{percent(toNumber(p.percentage) / 100)}</span
>
</div>
{/if}
</li>
{/each}
</ul>
{#snippet footer()}
<a class="text-brand hover:underline" href="/piggy-banks"
>Go to your piggy banks</a
>
{/snippet}
</Box>
{/if}
@@ -0,0 +1,157 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { parseSummary, span, summaryQuery } from "./data.js";
const DUE_LIMIT = 6;
const summary = createQuery(() => ({
...summaryQuery(period.start, period.end),
placeholderData: keepPreviousData,
}));
const bills = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () => all<Bill>("/v1/bills", span(period.start, period.end)),
queryKey: ["bills", period.start, period.end],
}));
const parsed = $derived(parseSummary(summary.data));
const meters = $derived(
(parsed["bills-paid"] ?? [])
.map((p) => {
const u = parsed["bills-unpaid"]?.find((e) => e.code === p.code);
const paid = Math.abs(p.amount);
const unpaid = Math.abs(u?.amount ?? 0);
return { cur: p, paid, total: paid + unpaid };
})
.concat(
(parsed["bills-unpaid"] ?? [])
.filter((u) => !parsed["bills-paid"]?.some((p) => p.code === u.code))
.map((u) => ({ cur: u, paid: 0, total: Math.abs(u.amount) }))
)
.filter((m) => m.total > 0)
);
const inPeriod = (dates: (string | null | undefined)[]) =>
dates
.map((d) => (d ?? "").slice(0, 10))
.filter((d) => d >= period.start && d <= period.end)
.sort();
const due = $derived(
(bills.data ?? [])
.filter((b) => b.attributes.active !== false)
.flatMap((b) => {
const expected = inPeriod(b.attributes.pay_dates ?? []);
const paid = inPeriod(
(b.attributes.paid_dates ?? []).map((p) => p.date)
).length;
const next = expected[paid];
if (!next) {
return [];
}
const amount =
(toNumber(b.attributes.amount_min) +
toNumber(b.attributes.amount_max)) /
2;
return [{ amount, bill: b, date: next }];
})
.sort((x, y) => x.date.localeCompare(y.date))
);
const fmt = (
amount: number,
c: { code: string; symbol: string; decimals: number }
) =>
formatMoney({
amount,
code: c.code,
decimals: c.decimals,
symbol: c.symbol,
});
</script>
{#if (bills.data?.length ?? 0) > 0}
<Box href="/subscriptions" title="Subscriptions">
<div
class="flex flex-col gap-3 transition-opacity {summary.isPlaceholderData
? "opacity-60"
: ""}"
>
{#if meters.length === 0}
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each meters as m (m.cur.code)}
<li class="flex flex-col gap-1">
<div
class="flex items-baseline justify-between gap-2 text-[12px]"
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
paid</span
>
<span class="text-cladd-fg-soft"
><span class="num text-cladd-fg"
>{fmt(m.total - m.paid, m.cur)}</span
>
to pay</span
>
</div>
<Meter
max={m.total}
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
/>
</li>
{/each}
</ul>
{/if}
{#if due.length > 0}
<div class="flex flex-col">
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
>Still to pay this period</span
>
<ul class="flex flex-col">
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
<li>
<a
class="-mx-1 flex h-10 items-center gap-2 rounded-sm px-1 text-[12.5px] sm:h-7 hover:bg-row-hover"
href="/subscriptions/show/{d.bill.id}"
>
<span class="num w-12 shrink-0 text-cladd-fg-soft"
>{formatDate(d.date, "short")}</span
>
<span class="min-w-0 flex-1 truncate"
>{d.bill.attributes.name}</span
>
<span class="num text-cladd-fg-soft"
>~{formatMoney({
amount: d.amount,
code: d.bill.attributes.currency_code,
decimals: d.bill.attributes.currency_decimal_places,
symbol: d.bill.attributes.currency_symbol,
})}</span
>
</a>
</li>
{/each}
</ul>
{#if due.length > DUE_LIMIT}
<a
class="pt-1 text-[12px] text-cladd-fg-soft hover:underline"
href="/subscriptions"
>and {due.length - DUE_LIMIT} more</a
>
{/if}
</div>
{/if}
</div>
{#snippet footer()}
<a class="text-brand hover:underline" href="/subscriptions"
>Go to your subscriptions</a
>
{/snippet}
</Box>
{/if}
+221
View File
@@ -0,0 +1,221 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
import { addDays, today } from "#lib/dates.js";
import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
export interface Cur {
code: string;
decimals: number;
symbol: string;
}
export interface SummaryEntry extends Cur {
amount: number;
noBudgets: boolean;
sub: string;
title: string;
}
export interface ChartSet {
currency_code: string;
currency_decimal_places: number;
currency_symbol?: string;
entries: Record<string, string | number>;
label: string;
pc_entries?: Record<string, string | number> | unknown[];
primary_currency_code?: string;
primary_currency_decimal_places?: number;
primary_currency_symbol?: string;
}
export interface InsightEntry {
currency_code: string;
difference: string;
difference_float?: number;
id?: string;
name?: string;
}
const SUMMARY_KEY = /^(.+)-in-([A-Z0-9]+)$/;
export function currency(code: string): Cur {
const found = session.currencies.find((c) => c.attributes.code === code);
return {
code,
decimals: found?.attributes.decimal_places ?? 2,
symbol: found?.attributes.symbol ?? code,
};
}
export const setCurrency = (set: ChartSet): Cur => ({
...currency(set.currency_code),
...(set.currency_symbol ? { symbol: set.currency_symbol } : {}),
});
export const money = (cur: Cur) => (value: number) =>
formatMoney({
amount: value,
code: cur.code,
decimals: cur.decimals,
symbol: cur.symbol,
});
export const axis = (cur: Cur) => (value: number) =>
compactMoney(value, cur.symbol);
export const primaryCode = () => session.primary?.code ?? "";
export function byCurrency<T>(
items: T[],
code: (item: T) => string
): [string, T[]][] {
const groups = new Map<string, T[]>();
for (const item of items) {
const key = code(item);
const list = groups.get(key);
if (list) {
list.push(item);
} else {
groups.set(key, [item]);
}
}
const primary = primaryCode();
return [...groups.entries()].sort(
([a, la], [b, lb]) =>
Number(b === primary) - Number(a === primary) || lb.length - la.length
);
}
export function parseSummary(
raw: Record<string, BasicSummaryEntry> | undefined
): Record<string, SummaryEntry[]> {
const result: Record<string, SummaryEntry[]> = {};
for (const [key, entry] of Object.entries(raw ?? {})) {
const match = key.match(SUMMARY_KEY);
if (!match) {
continue;
}
const extra = entry as BasicSummaryEntry & {
no_available_budgets?: boolean;
};
const list = result[match[1]] ?? [];
list.push({
amount: toNumber(entry.monetary_value),
code: entry.currency_code ?? match[2],
decimals: entry.currency_decimal_places ?? 2,
noBudgets: extra.no_available_budgets === true,
sub: entry.sub_title ?? "",
symbol: entry.currency_symbol ?? match[2],
title: entry.title ?? "",
});
result[match[1]] = list;
}
const primary = primaryCode();
for (const list of Object.values(result)) {
list.sort(
(a, b) =>
Number(b.code === primary) - Number(a.code === primary) ||
Math.abs(b.amount) - Math.abs(a.amount)
);
}
return result;
}
export function hasPrimary(sets: ChartSet[]): boolean {
return (
session.prefs.convert_to_primary === true &&
sets.length > 0 &&
sets.every((s) => s.pc_entries && !Array.isArray(s.pc_entries))
);
}
export function upTo(end: string): string {
const now = today();
return end < now ? end : now;
}
export function periodQuery(extra: Record<string, unknown> = {}) {
return { end: period.end, start: period.start, ...extra };
}
export function hrefWith(
url: { href: string },
changes: Record<string, string | null>
): string {
const next = new URL(url.href);
for (const [name, value] of Object.entries(changes)) {
if (value === null) {
next.searchParams.delete(name);
} else {
next.searchParams.set(name, value);
}
}
next.searchParams.set("start", period.start);
next.searchParams.set("end", period.end);
return next.pathname + next.search;
}
export const summaryQuery = (start: string, end: string) => ({
queryFn: () =>
api<Record<string, BasicSummaryEntry>>("/v1/summary/basic", {
query: { end, start },
}),
queryKey: ["summary", start, end],
});
export const accountsChartQuery = (
start: string,
end: string,
everything = false
) => ({
queryFn: () =>
api<ChartSet[]>("/v1/chart/account/overview", {
query: {
...span(start, end),
period: "1D",
preselected: everything ? "all" : undefined,
},
}),
queryKey: ["chart-accounts", start, end, everything],
});
export function seriesPoints(set: ChartSet, converted: boolean) {
const entries = (converted ? set.pc_entries : set.entries) as
| Record<string, string | number>
| undefined;
return Object.entries(entries ?? {}).map(([date, value]) => ({
x: date.slice(0, 10),
y: toNumber(value),
}));
}
export function setPrimary(set: ChartSet): Cur {
return currency(set.primary_currency_code ?? set.currency_code);
}
export function balanceDate(start: string, end: string): string {
const until = upTo(end);
return until < start ? end : until;
}
export function balanceRange(start: string, end: string) {
const until = balanceDate(start, end);
return until > start ? { date: until, end: until, start } : { date: until };
}
export function span(start: string, end: string) {
return { end: end > start ? end : addDays(start, 1), start };
}
export const accountsQuery = (type: string, start: string, end: string) => ({
queryFn: () =>
all<Account>("/v1/accounts", { ...balanceRange(start, end), type }),
queryKey: ["accounts", type, start, end],
});
export function proportional(counts: number[]): string {
return counts.map((n) => `minmax(0, ${Math.max(2, n)}fr)`).join(" ");
}