feat(dashboard,accounts,charts): v1 dashboard redrawn, account lists and pages, svg chart kit
This commit is contained in:
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}
|
||||
@@ -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(" ");
|
||||
}
|
||||
Reference in new issue
Block a user