feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days
The right column drops the box per account for one table per currency with sparklines and the last five transactions on l, a list of what was entered since the last look (x marks reviewed), bills and piggy bank dates in the next seven days, and debts with friends net per currency. The left column opens with spending so far against a typical month, and $ shows totals in UAH.
This commit is contained in:
1 parent
cea51281cd
commit
8c4e72666b
11 files changed
+1452
-312
No files matched your search
@@ -0,0 +1,381 @@
|
||||
<script lang="ts">
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import {
|
||||
createQueries,
|
||||
createQuery,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/svelte-query";
|
||||
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 { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||
import { type Row, useColumn } from "#lib/dashboard/column.svelte.js";
|
||||
import {
|
||||
formatPrimary,
|
||||
inPrimaryTotal,
|
||||
ratesQuery,
|
||||
} from "#lib/dashboard/primary.svelte.js";
|
||||
import { addDays, today } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import {
|
||||
groupTitle,
|
||||
signedAmount,
|
||||
editUrl as txEditUrl,
|
||||
showUrl as txShowUrl,
|
||||
} from "#lib/transactions/split.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
accountsChartQuery,
|
||||
accountsQuery,
|
||||
byCurrency,
|
||||
type Cur,
|
||||
seriesPoints,
|
||||
} from "./data.js";
|
||||
|
||||
const RECENT = 5;
|
||||
const TREND_DAYS = 30;
|
||||
const HISTORY_DAYS = 365;
|
||||
|
||||
const now = today();
|
||||
let expanded = $state<string[]>([]);
|
||||
|
||||
const assets = createQuery(() => ({
|
||||
...accountsQuery("asset", period.start, period.end),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
const chart = createQuery(() =>
|
||||
accountsChartQuery(addDays(now, -TREND_DAYS), now)
|
||||
);
|
||||
const rates = createQuery(() => ratesQuery());
|
||||
const recent = createQueries(() => ({
|
||||
queries: expanded.map((id) => ({
|
||||
queryFn: () =>
|
||||
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
||||
query: {
|
||||
end: now,
|
||||
limit: RECENT,
|
||||
start: addDays(now, -HISTORY_DAYS),
|
||||
},
|
||||
}),
|
||||
queryKey: ["account-recent", id, now],
|
||||
})),
|
||||
}));
|
||||
|
||||
const shown = $derived.by(() => {
|
||||
const list = (assets.data ?? [])
|
||||
.filter((a) => a.attributes.active !== false)
|
||||
.sort((a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0));
|
||||
if (!("frontpageAccounts" in session.prefs)) {
|
||||
return list;
|
||||
}
|
||||
const ids = new Set(session.frontpageAccounts);
|
||||
return list.filter((a) => ids.has(a.id));
|
||||
});
|
||||
|
||||
const trends = $derived(
|
||||
new Map(
|
||||
(chart.data ?? []).map((set) => [
|
||||
set.label,
|
||||
seriesPoints(set, false).map((p) => p.y),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
function sumSeries(series: number[][]): number[] {
|
||||
const length = Math.max(0, ...series.map((s) => s.length));
|
||||
return Array.from({ length }, (_, i) =>
|
||||
series.reduce((sum, s) => sum + (s[i] ?? 0), 0)
|
||||
);
|
||||
}
|
||||
|
||||
const groups = $derived(
|
||||
byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
|
||||
([code, accounts]) => {
|
||||
const first = accounts[0].attributes;
|
||||
return {
|
||||
accounts,
|
||||
balance: accounts.reduce(
|
||||
(s, a) => s + toNumber(a.attributes.current_balance),
|
||||
0
|
||||
),
|
||||
change: accounts.reduce(
|
||||
(s, a) => s + toNumber(a.attributes.balance_difference),
|
||||
0
|
||||
),
|
||||
code,
|
||||
cur: {
|
||||
code,
|
||||
decimals: first.currency_decimal_places ?? 2,
|
||||
symbol: first.currency_symbol ?? code,
|
||||
},
|
||||
trend: sumSeries(
|
||||
accounts.map((a) => trends.get(a.attributes.name) ?? [])
|
||||
),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const converted = $derived({
|
||||
balance: inPrimaryTotal(
|
||||
groups.map((g) => ({ amount: g.balance, code: g.code })),
|
||||
rates.data
|
||||
),
|
||||
change: inPrimaryTotal(
|
||||
groups.map((g) => ({ amount: g.change, code: g.code })),
|
||||
rates.data
|
||||
),
|
||||
});
|
||||
|
||||
const recentRows = $derived(
|
||||
new Map(
|
||||
expanded.map((id, i) => [
|
||||
id,
|
||||
(recent[i]?.data?.data ?? [])
|
||||
.flatMap((group) =>
|
||||
group.attributes.transactions
|
||||
.filter((s) => s.source_id === id || s.destination_id === id)
|
||||
.map((split, n) => ({
|
||||
date: split.date.slice(0, 10),
|
||||
group,
|
||||
key: `acc-${id}-tx-${group.id}-${n}`,
|
||||
money: signedAmount(split, id),
|
||||
title:
|
||||
group.attributes.transactions.length > 1
|
||||
? `${groupTitle(group)}: ${split.description}`
|
||||
: split.description,
|
||||
}))
|
||||
)
|
||||
.slice(0, RECENT),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const accountKey = (id: string) => `acc-${id}`;
|
||||
|
||||
function expand(id: string) {
|
||||
if (expanded.includes(id)) {
|
||||
const first = recentRows.get(id)?.[0];
|
||||
if (first) {
|
||||
column.point(first.key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expanded = [...expanded, id];
|
||||
}
|
||||
|
||||
function fold(id: string) {
|
||||
expanded = expanded.filter((e) => e !== id);
|
||||
column.point(accountKey(id));
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
if (expanded.includes(id)) {
|
||||
fold(id);
|
||||
} else {
|
||||
expand(id);
|
||||
}
|
||||
}
|
||||
|
||||
const column = useColumn(1, () =>
|
||||
groups.flatMap((g) =>
|
||||
g.accounts.flatMap((account): Row[] => [
|
||||
{
|
||||
edit: () => goto(editUrl(account.id)),
|
||||
expand: () => expand(account.id),
|
||||
fold: () => fold(account.id),
|
||||
key: accountKey(account.id),
|
||||
open: () => goto(showUrl(account.id)),
|
||||
},
|
||||
...(recentRows.get(account.id) ?? []).map((tx) => ({
|
||||
edit: () => goto(txEditUrl(tx.group.id)),
|
||||
fold: () => fold(account.id),
|
||||
key: tx.key,
|
||||
open: () => goto(txShowUrl(tx.group.id)),
|
||||
})),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const trendColor = (values: number[]) =>
|
||||
(values.at(-1) ?? 0) < (values[0] ?? 0)
|
||||
? "var(--ff-expense)"
|
||||
: "var(--ff-income)";
|
||||
</script>
|
||||
|
||||
{#snippet spark(
|
||||
values: number[]
|
||||
)}
|
||||
{#if values.length > 1}
|
||||
<Sparkline color={trendColor(values)} height={18} {values} width={56} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet balance(
|
||||
amount: number,
|
||||
cur: Cur
|
||||
)}
|
||||
<span class="num font-medium {amount < 0 ? "text-expense" : "text-cladd-fg"}"
|
||||
>{formatMoney({ amount, ...cur })}</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<Box body="p-0" href="/accounts/asset" title="Accounts">
|
||||
{#snippet tools()}
|
||||
{#if converted.balance !== null}
|
||||
<span class="num font-semibold text-[13px] text-cladd-fg"
|
||||
>{formatPrimary(converted.balance)}</span
|
||||
>
|
||||
{#if converted.change}
|
||||
<span
|
||||
class="num text-[12px] {converted.change < 0
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>{formatPrimary(converted.change, true)}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if shown.length === 0}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">
|
||||
{assets.isPending ? "" : "No accounts on the home screen."}
|
||||
</p>
|
||||
{:else}
|
||||
<table
|
||||
class="w-full border-collapse text-[13px] transition-opacity {assets.isPlaceholderData
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
<thead>
|
||||
<tr class="h-7 text-[11px] text-cladd-fg-soft">
|
||||
<th class="w-full pl-3 text-left font-normal" scope="col">Account</th>
|
||||
<th class="px-2 text-right font-normal" scope="col">Balance</th>
|
||||
<th class="px-2 text-right font-normal" scope="col">This period</th>
|
||||
<th
|
||||
class="hidden pr-3 text-right font-normal sm:table-cell"
|
||||
scope="col"
|
||||
>
|
||||
{TREND_DAYS}
|
||||
days
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{#each groups as group (group.code)}
|
||||
<tbody class="border-cladd-outline border-t">
|
||||
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
|
||||
<th
|
||||
class="pl-3 text-left text-[12px] text-cladd-fg"
|
||||
scope="rowgroup"
|
||||
>
|
||||
{group.code}
|
||||
</th>
|
||||
<td class="whitespace-nowrap px-2 text-right">
|
||||
{@render balance(group.balance, group.cur)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 text-right">
|
||||
{#if group.change !== 0}
|
||||
<Amount amount={group.change} signed {...group.cur} />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="hidden pr-3 sm:table-cell">
|
||||
<div class="flex justify-end">{@render spark(group.trend)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#each group.accounts as account (account.id)}
|
||||
{@const a = account.attributes}
|
||||
{@const key = accountKey(account.id)}
|
||||
{@const open = expanded.includes(account.id)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
|
||||
key
|
||||
)}"
|
||||
id={column.id(key)}
|
||||
onclick={() => column.point(key)}
|
||||
>
|
||||
<td class="max-w-0 pl-1">
|
||||
<div class="flex min-w-0 items-center">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-label="Last transactions of {a.name}"
|
||||
class="grid size-6 shrink-0 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:text-cladd-fg"
|
||||
onclick={() => toggle(account.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
class="size-3.5 transition-transform {open
|
||||
? "rotate-90"
|
||||
: ""}"
|
||||
/>
|
||||
</button>
|
||||
<a
|
||||
class="truncate text-cladd-fg hover:underline"
|
||||
href={showUrl(account.id)}
|
||||
title={a.name}
|
||||
>{a.name}</a
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 text-right">
|
||||
{@render balance(toNumber(a.current_balance), group.cur)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 text-right">
|
||||
{#if toNumber(a.balance_difference) !== 0}
|
||||
<Amount amount={a.balance_difference} signed {...group.cur} />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="hidden pr-3 sm:table-cell">
|
||||
<div class="flex justify-end">
|
||||
{@render spark(trends.get(a.name) ?? [])}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if open}
|
||||
{#each recentRows.get(account.id) ?? [] as tx (tx.key)}
|
||||
<tr
|
||||
class="h-7 text-[12px] {column.rowClass(tx.key)}"
|
||||
id={column.id(tx.key)}
|
||||
onclick={() => column.point(tx.key)}
|
||||
>
|
||||
<td class="max-w-0 pl-7" colspan="2">
|
||||
<a
|
||||
class="flex min-w-0 items-baseline gap-2 hover:underline"
|
||||
href={txShowUrl(tx.group.id)}
|
||||
>
|
||||
<span class="num w-12 shrink-0 text-cladd-fg-soft"
|
||||
>{formatDate(tx.date, "short")}</span
|
||||
>
|
||||
<span class="truncate text-cladd-fg-soft" title={tx.title}
|
||||
>{tx.title}</span
|
||||
>
|
||||
</a>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 text-right">
|
||||
<Amount {...tx.money} signed />
|
||||
</td>
|
||||
<td class="hidden sm:table-cell"></td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="h-7 text-[12px]">
|
||||
<td class="pl-7 text-cladd-fg-soft" colspan="4">
|
||||
{recent[expanded.indexOf(account.id)]?.isPending
|
||||
? ""
|
||||
: "Nothing in the last year."}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/each}
|
||||
</table>
|
||||
{/if}
|
||||
</Box>
|
||||
Reference in new issue
Block a user