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,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(" ");
}