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:
hh committed 2026-10-16 15:14:03 +00:00
1 parent cea51281cd
commit 8c4e72666b
11 files changed
+1452 -312

No files matched your search

@@ -1,188 +0,0 @@
<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,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>
+121 -62
View File
@@ -1,18 +1,25 @@
<script lang="ts"> <script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { showUrl } from "#lib/components/accounts/links.js"; import { editUrl, showUrl } from "#lib/components/accounts/links.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import BarChart from "#lib/components/charts/BarChart.svelte"; import { useColumn } from "#lib/dashboard/column.svelte.js";
import { tones } from "#lib/components/charts/chart.js"; import {
import { toNumber } from "#lib/format.js"; formatPrimary,
inPrimaryTotal,
ratesQuery,
} from "#lib/dashboard/primary.svelte.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { accountsQuery, byCurrency, money } from "./data.js"; import { goto } from "$app/navigation";
import { accountsQuery, byCurrency } from "./data.js";
const liabilities = createQuery(() => ({ const liabilities = createQuery(() => ({
...accountsQuery("liabilities", period.start, period.end), ...accountsQuery("liabilities", period.start, period.end),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
})); }));
const rates = createQuery(() => ratesQuery());
const groups = $derived( const groups = $derived(
byCurrency( byCurrency(
(liabilities.data ?? []).filter( (liabilities.data ?? []).filter(
@@ -23,83 +30,135 @@
(a) => a.attributes.currency_code ?? "" (a) => a.attributes.currency_code ?? ""
).map(([code, list]) => { ).map(([code, list]) => {
const first = list[0].attributes; const first = list[0].attributes;
const format = money({ const cur = {
code, code,
decimals: first.currency_decimal_places ?? 2, decimals: first.currency_decimal_places ?? 2,
symbol: first.currency_symbol ?? code, symbol: first.currency_symbol ?? code,
}); };
const bars = list const rows = list
.map((a) => { .map((a) => {
const due = Math.abs(toNumber(a.attributes.debt_amount)); const due = Math.abs(toNumber(a.attributes.debt_amount));
const value =
a.attributes.liability_direction === "credit" ? due : -due;
return { return {
color: value > 0 ? tones.income : tones.expense, id: a.id,
href: showUrl(a.id), key: `debt-${a.id}`,
key: a.id, name: a.attributes.name,
label: a.attributes.name, value: a.attributes.liability_direction === "credit" ? due : -due,
value,
}; };
}) })
.sort((x, y) => y.value - x.value); .sort((x, y) => y.value - x.value);
const owed = bars return {
.filter((b) => b.value > 0) code,
.reduce((s, b) => s + b.value, 0); cur,
const owe = bars largest: Math.max(...rows.map((r) => Math.abs(r.value))),
.filter((b) => b.value < 0) net: rows.reduce((s, r) => s + r.value, 0),
.reduce((s, b) => s - b.value, 0); rows,
return { bars, code, format, owe, owed }; };
}) })
); );
const nets = $derived.by(() => {
const total = inPrimaryTotal(
groups.map((g) => ({ amount: g.net, code: g.code })),
rates.data
);
if (total === null) {
return groups.map((g) => ({
code: g.code,
net: g.net,
text: formatMoney({ amount: Math.abs(g.net), ...g.cur }),
}));
}
return [{ code: "all", net: total, text: formatPrimary(Math.abs(total)) }];
});
const column = useColumn(4, () =>
groups.flatMap((g) =>
g.rows.map((r) => ({
edit: () => goto(editUrl(r.id)),
key: r.key,
open: () => goto(showUrl(r.id)),
}))
)
);
</script> </script>
{#snippet more()}
<a
class="text-brand hover:underline dark:text-(--ff-series-1)"
href="/accounts/liabilities"
>See your liabilities</a
>
{/snippet}
{#if groups.length > 0} {#if groups.length > 0}
<Box href="/accounts/liabilities" title="Debts with friends"> <Box
body="p-0"
footer={more}
href="/accounts/liabilities"
title="Debts with friends"
>
<div <div
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
? "opacity-60" ? "opacity-60"
: ""}" : ""}"
> >
{#each groups as group (group.code)} {#each nets as g (g.code)}
<div class="flex flex-col gap-1.5"> {#if g.net !== 0}
<div class="flex items-baseline gap-3 text-[12px]"> <span class="text-[12px] text-cladd-fg-soft"
<span ><span
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide" class="num font-semibold text-[15px] {g.net > 0
>{group.code}</span ? "text-income"
: "text-expense"}"
>{g.text}</span
> >
{#if group.owed > 0} {g.net > 0 ? "owed to me" : "I owe"}</span
<span class="text-cladd-fg-soft" >
>Owed to me {/if}
<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} {/each}
</div> </div>
{#snippet footer()} <table class="w-full border-collapse text-[13px]">
<a {#each groups as g (g.code)}
class="text-brand dark:text-(--ff-series-1) hover:underline" <tbody class="border-cladd-outline border-t">
href="/accounts/liabilities" {#each g.rows as r, i (r.key)}
>See your liabilities</a <tr
> class="h-7 {i > 0
{/snippet} ? "border-cladd-outline/60 border-t"
: ""} {column.rowClass(r.key)}"
id={column.id(r.key)}
onclick={() => column.point(r.key)}
>
<td class="w-full max-w-0 pl-3">
<a
class="block truncate text-cladd-fg hover:underline"
href={showUrl(r.id)}
title={r.name}
>{r.name}</a
>
</td>
<td class="px-2">
<div
class="flex h-1.5 w-16 overflow-hidden rounded-full bg-cladd-surface-next"
>
<span
class="h-full rounded-full {r.value > 0
? "bg-income"
: "bg-expense"}"
style:width="{(Math.abs(r.value) / g.largest) * 100}%"
></span>
</div>
</td>
<td
class="num whitespace-nowrap pr-3 pl-2 text-right font-medium {r.value >
0
? "text-income"
: "text-expense"}"
>
{formatMoney({ amount: r.value, ...g.cur }, true)}
</td>
</tr>
{/each}
</tbody>
{/each}
</table>
</Box> </Box>
{/if} {/if}
+187
View File
@@ -0,0 +1,187 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all, lists } from "#lib/api/lists.js";
import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { 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 { goto } from "$app/navigation";
import { byCurrency, type Cur } from "./data.js";
const DAYS = 7;
const HREF = `/subscriptions?horizon=${DAYS}`;
interface Due {
amount: number;
approx: boolean;
cur: Cur;
date: string;
edit: string;
href: string;
key: string;
name: string;
piggy: boolean;
}
const now = today();
const end = addDays(now, DAYS);
const inWindow = (value: string | null | undefined) => {
const day = (value ?? "").slice(0, 10);
return day >= now && day <= end;
};
const bills = createQuery(() => ({
queryFn: () => all<Bill>("/v1/bills", { end, start: now }),
queryKey: ["bills-due", now, end],
}));
const piggies = createQuery(() => lists.piggyBanks());
const rates = createQuery(() => ratesQuery());
const items = $derived.by<Due[]>(() => {
const due = (bills.data ?? [])
.filter((b) => b.attributes.active !== false)
.flatMap((b) => {
const a = b.attributes;
const expected = (a.pay_dates ?? []).filter(inWindow).sort();
const paid = (a.paid_dates ?? []).filter((p) => inWindow(p.date));
const min = toNumber(a.amount_min);
const max = toNumber(a.amount_max);
return expected.slice(paid.length).map((date) => ({
amount: (min + max) / 2,
approx: min !== max,
cur: {
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
symbol: a.currency_symbol ?? "",
},
date: date.slice(0, 10),
edit: `/subscriptions/edit/${b.id}`,
href: `/subscriptions/show/${b.id}`,
key: `due-bill-${b.id}-${date.slice(0, 10)}`,
name: a.name ?? "",
piggy: false,
}));
});
const saving = (piggies.data ?? [])
.filter(
(p) =>
p.attributes.active !== false &&
toNumber(p.attributes.left_to_save) > 0 &&
inWindow(p.attributes.target_date)
)
.map((p) => {
const a = p.attributes;
return {
amount: toNumber(a.left_to_save),
approx: false,
cur: {
code: a.currency_code ?? "",
decimals: a.currency_decimal_places ?? 2,
symbol: a.currency_symbol ?? "",
},
date: (a.target_date ?? "").slice(0, 10),
edit: `/piggy-banks/edit/${p.id}`,
href: `/piggy-banks/show/${p.id}`,
key: `due-piggy-${p.id}`,
name: a.name ?? "",
piggy: true,
};
});
return [...due, ...saving].sort(
(x, y) => x.date.localeCompare(y.date) || x.name.localeCompare(y.name)
);
});
const totals = $derived(
byCurrency(items, (i) => i.cur.code).map(([code, list]) => ({
amount: list.reduce((s, i) => s + i.amount, 0),
approx: list.some((i) => i.approx),
code,
cur: list[0].cur,
}))
);
const converted = $derived(inPrimaryTotal(totals, rates.data));
const money = (amount: number, cur: Cur, approx: boolean) =>
`${approx ? "~" : ""}${formatMoney({ amount, ...cur })}`;
function dayLabel(date: string) {
if (date === now) {
return "Today";
}
return date === addDays(now, 1) ? "Tomorrow" : formatDate(date, "day");
}
const column = useColumn(3, () =>
items.map((item) => ({
edit: () => goto(item.edit),
key: item.key,
open: () => goto(item.href),
}))
);
</script>
{#snippet more()}
<a class="text-brand hover:underline dark:text-(--ff-series-1)" href={HREF}
>Go to your subscriptions</a
>
{/snippet}
<Box body="p-0" footer={more} href={HREF} title="Next {DAYS} days">
{#if items.length === 0}
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
{bills.isPending ? "" : `Nothing due in the next ${DAYS} days.`}
</p>
{:else}
<div
class="num flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 font-semibold text-[15px] text-cladd-fg"
>
{#if converted === null}
{#each totals as t (t.code)}
<span>{money(t.amount, t.cur, t.approx)}</span>
{/each}
{:else}
<span>{formatPrimary(converted)}</span>
{/if}
</div>
<table class="w-full border-collapse text-[13px]">
<tbody>
{#each items as item (item.key)}
<tr
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
item.key
)}"
id={column.id(item.key)}
onclick={() => column.point(item.key)}
>
<td class="whitespace-nowrap pr-2 pl-3 text-cladd-fg-soft">
{dayLabel(item.date)}
</td>
<td class="w-full max-w-0">
<a
class="block truncate text-cladd-fg hover:underline"
href={item.href}
title={item.name}
>{item.name}<span class="text-cladd-fg-soft"
>{item.piggy ? " · piggy bank" : ""}</span
></a
>
</td>
<td
class="num whitespace-nowrap pr-3 pl-2 text-right text-cladd-fg"
>
{money(item.amount, item.cur, item.approx)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
@@ -0,0 +1,183 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Page, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { useColumn } from "#lib/dashboard/column.svelte.js";
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
import { addDays, iso } from "#lib/dates.js";
import { formatDate, formatDateTime } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import {
editUrl,
groupTitle,
showUrl,
sumByCurrency,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
const FETCH = 200;
const SHOW = 10;
const since = reviewed.until;
const sinceDay = addDays(iso(new Date(since)), -1);
const searchHref = `/search?search=${encodeURIComponent(`created_at_after:${sinceDay}`)}`;
const at = (group: { attributes: TransactionGroup }) =>
group.attributes.created_at ?? "";
async function load() {
const page = await api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: FETCH, query: `created_at_after:${sinceDay}` },
});
if (page.data.length < FETCH) {
reviewed.compact(page.data.map((g) => ({ at: at(g), id: g.id })));
}
return page;
}
const inbox = createQuery(() => ({
queryFn: load,
queryKey: ["dashboard-inbox", sinceDay],
refetchOnWindowFocus: true,
}));
const fetched = $derived(inbox.data?.data ?? []);
const entries = $derived(
fetched
.filter((g) => Date.parse(at(g)) > Date.parse(since))
.sort((a, b) => Date.parse(at(b)) - Date.parse(at(a)))
.map((group) => {
const [first] = group.attributes.transactions;
return {
at: at(group),
date: first?.date.slice(0, 10) ?? "",
group,
id: group.id,
money: sumByCurrency(group.attributes.transactions)[0],
note: (first?.notes ?? "").trim().split("\n")[0],
title: groupTitle(group),
};
})
);
const shown = $derived(entries.slice(0, SHOW));
const fresh = $derived(entries.filter((e) => reviewed.isNew(e)).length);
const markAll = () => reviewed.all(entries);
useKeys(() => [
{
group: "Dashboard",
keys: "*",
label: "Mark all entries reviewed",
run: markAll,
},
]);
const column = useColumn(2, () =>
shown.map((e) => ({
edit: () => goto(editUrl(e.id)),
key: `inbox-${e.id}`,
open: () => goto(showUrl(e.id)),
review: () => reviewed.toggle(e),
}))
);
</script>
{#snippet actions()}
{#if fresh > 0}
<button
class="text-brand hover:underline dark:text-(--ff-series-1)"
onclick={markAll}
type="button"
>
Mark all reviewed <span class="kbd">*</span>
</button>
{/if}
{#if entries.length > SHOW}
<a class="ml-auto text-cladd-fg-soft hover:underline" href={searchHref}
>and {entries.length - SHOW} more</a
>
{/if}
{/snippet}
<Box
body="p-0"
footer={fresh > 0 || entries.length > SHOW ? actions : undefined}
href={searchHref}
title="Entered since you last looked"
>
{#snippet tools()}
{#if fresh > 0}
<span class="num text-[12px] text-cladd-fg-soft">{fresh} new</span>
{/if}
{/snippet}
{#if entries.length === 0}
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
{inbox.isPending ? "" : `Nothing new since ${formatDateTime(since)}.`}
</p>
{:else}
<table class="w-full border-collapse text-[13px]">
<tbody>
{#each shown as e, i (e.id)}
{@const key = `inbox-${e.id}`}
{@const isNew = reviewed.isNew(e)}
<tr
class="{i > 0
? "border-cladd-outline/60 border-t"
: ""} {column.rowClass(key)}"
id={column.id(key)}
onclick={() => column.point(key)}
>
<td class="w-7 py-1 pl-2 align-top">
<button
aria-label={isNew ? "Mark reviewed" : "Mark as new"}
aria-pressed={!isNew}
class="grid size-5 place-items-center rounded-full"
onclick={() => reviewed.toggle(e)}
type="button"
>
<span
class="size-2 rounded-full {isNew
? "bg-brand dark:bg-(--ff-series-1)"
: "border border-cladd-fg-softer"}"
></span>
</button>
</td>
<td
class="num w-12 whitespace-nowrap py-1 align-top text-cladd-fg-soft leading-5"
>
{formatDate(e.date, "short")}
</td>
<td class="w-full max-w-0 py-1 pl-2 align-top leading-5">
<a
class="block truncate hover:underline {isNew
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft"}"
href={showUrl(e.id)}
title={e.title}
>{e.title}</a
>
{#if e.note}
<p
class="truncate text-[12px] text-cladd-fg-soft"
title={e.note}
>
{e.note}
</p>
{/if}
</td>
<td
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
>
{#if e.money}
<Amount {...e.money} />
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Box>
+203
View File
@@ -0,0 +1,203 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import Box from "#lib/components/Box.svelte";
import {
formatPrimary,
inPrimaryTotal,
median,
ratesQuery,
} from "#lib/dashboard/primary.svelte.js";
import {
addDays,
daysBetween,
parse,
type Range,
shiftPeriod,
today,
} from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js";
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
let { class: className = "" }: { class?: string } = $props();
const MONTHS = 6;
const MIN_MONTHS = 3;
const SUFFIX: Record<string, string> = {
few: "rd",
one: "st",
other: "th",
two: "nd",
};
const ordinals = new Intl.PluralRules("en-US", { type: "ordinal" });
const now = today();
const cutoff = $derived(period.end < now ? period.end : now);
const visible = $derived(cutoff >= period.start);
const ranges = $derived.by(() => {
const days = daysBetween(period.start, cutoff);
const list: Range[] = [{ end: cutoff, start: period.start }];
let { range } = period;
for (let i = 0; i < MONTHS; i += 1) {
range = shiftPeriod(range, period.view, -1);
const end = addDays(range.start, days);
list.push({ end: end < range.end ? end : range.end, start: range.start });
}
return list;
});
const rates = createQuery(() => ratesQuery());
const pace = createQuery(() => ({
enabled: visible,
placeholderData: keepPreviousData,
queryFn: () =>
Promise.all(
ranges.map((r) =>
api<InsightEntry[]>("/v1/insight/expense/total", {
query: span(r.start, r.end),
})
)
),
queryKey: ["dashboard-pace", ranges.map((r) => r.start).join(), cutoff],
}));
const total = (list: InsightEntry[], code: string) =>
Math.abs(
list
.filter((e) => e.currency_code === code)
.reduce((s, e) => s + toNumber(e.difference), 0)
);
const rows = $derived.by(() => {
const [current = [], ...past] = pace.data ?? [];
const codes = [
...new Set([current, ...past].flat().map((e) => e.currency_code)),
];
return byCurrency(codes, (code) => code)
.map(([code]) => {
const history = past
.map((list) => total(list, code))
.filter((v) => v > 0);
const typical = history.length >= MIN_MONTHS ? median(history) : null;
const spent = total(current, code);
const cur = currency(code);
return {
code,
format: (amount: number) => formatMoney({ amount, ...cur }),
spent,
typical,
};
})
.filter((r) => r.spent > 0 || r.typical !== null);
});
const title = $derived.by(() => {
if (period.view !== "1M") {
return `Spent by ${formatDate(cutoff, "short")}`;
}
const day = parse(cutoff).getDate();
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
});
const lines = $derived.by(() => {
const parts = (pick: (r: (typeof rows)[number]) => number | null) =>
rows.map((r) => ({ amount: pick(r) ?? 0, code: r.code }));
const spent = inPrimaryTotal(
parts((r) => r.spent),
rates.data
);
if (spent === null || rows.length < 2) {
return rows;
}
const known = rows.every((r) => r.typical !== null);
const typical = known
? inPrimaryTotal(
parts((r) => r.typical),
rates.data
)
: null;
return [...rows, { code: "All", format: formatPrimary, spent, typical }];
});
</script>
{#if visible && rows.length > 0}
<Box body="p-0" class={className} {title}>
{#snippet tools()}
<span class="text-[12px] text-cladd-fg-soft"
>Typical: median of {MONTHS} months</span
>
{/snippet}
<table
class="w-full border-collapse text-[13px] transition-opacity {pace.isPlaceholderData
? "opacity-60"
: ""}"
>
<thead>
<tr class="h-7 text-[11px] text-cladd-fg-soft">
<th class="pl-3 text-left font-normal" scope="col">Currency</th>
<th class="px-2 text-right font-normal" scope="col">Spent</th>
<th class="px-2 text-right font-normal" scope="col">Typical</th>
<th class="px-2 text-right font-normal" scope="col">Difference</th>
<th class="hidden w-full pr-3 font-normal sm:table-cell" scope="col">
<span class="sr-only">Spent against typical</span>
</th>
</tr>
</thead>
<tbody>
{#each lines as r (r.code)}
{@const diff = r.typical === null ? null : r.spent - r.typical}
{@const scale = Math.max(r.spent, r.typical ?? 0) || 1}
<tr class="h-7 border-cladd-outline/60 border-t">
<th
class="pl-3 text-left font-semibold text-[12px] text-cladd-fg"
scope="row"
>
{r.code}
</th>
<td
class="num whitespace-nowrap px-2 text-right font-semibold text-cladd-fg"
>
{r.format(r.spent)}
</td>
<td
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
>
{r.typical === null ? "n/a" : r.format(r.typical)}
</td>
<td
class="num whitespace-nowrap px-2 text-right {diff !== null &&
diff > 0
? "text-expense"
: "text-income"}"
>
{#if diff !== null}
{r.format(Math.abs(diff))}
{diff > 0 ? "over" : "under"}
{/if}
</td>
<td class="hidden pr-3 pl-2 sm:table-cell">
{#if r.typical !== null}
<div
class="relative h-2 min-w-24 max-w-80 rounded-full bg-cladd-surface-next"
>
<span
class="absolute inset-y-0 left-0 rounded-full {diff !==
null && diff > 0
? "bg-expense"
: "bg-brand"}"
style:width="{(r.spent / scale) * 100}%"
></span>
<span
class="absolute -top-1 -bottom-1 w-0.5 -translate-x-1/2 rounded-full bg-cladd-fg"
style:left="{(r.typical / scale) * 100}%"
></span>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</Box>
{/if}
@@ -4,6 +4,7 @@
import type { Bill } from "#lib/api/types.js"; import type { Bill } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte"; import Box from "#lib/components/Box.svelte";
import Meter from "#lib/components/charts/Meter.svelte"; import Meter from "#lib/components/charts/Meter.svelte";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js"; import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { parseSummary, span, summaryQuery } from "./data.js"; import { parseSummary, span, summaryQuery } from "./data.js";
@@ -50,7 +51,7 @@
(b.attributes.paid_dates ?? []).map((p) => p.date) (b.attributes.paid_dates ?? []).map((p) => p.date)
).length; ).length;
const next = expected[paid]; const next = expected[paid];
if (!next) { if (!next || next >= today()) {
return []; return [];
} }
const amount = const amount =
@@ -111,7 +112,7 @@
{#if due.length > 0} {#if due.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft" <span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
>Still to pay this period</span >Not paid yet</span
> >
<ul class="flex flex-col"> <ul class="flex flex-col">
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)} {#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
+108
View File
@@ -0,0 +1,108 @@
import { getContext, onMount, setContext } from "svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
export interface Row {
edit?: () => void;
expand?: () => void;
fold?: () => void;
key: string;
open: () => void;
review?: () => void;
}
interface Section {
order: number;
rows: () => Row[];
}
const CONTEXT = Symbol("dashboard-column");
const GROUP = "Dashboard";
class Column {
sections = $state.raw<Section[]>([]);
rows = $derived(this.sections.flatMap((section) => section.rows()));
index = $derived(new Map(this.rows.map((row, i) => [row.key, i])));
nav = new ListNav<Row>({
edit: (row) => row.edit?.(),
extra: () => [
{
group: GROUP,
keys: "Enter",
label: "Expand account or open row",
run: () => this.run((row) => (row.expand ?? row.open)()),
},
{
group: GROUP,
keys: "l",
label: "Show last transactions of the account",
run: () => this.run((row) => (row.expand ?? row.open)()),
},
{
group: GROUP,
keys: "h",
label: "Fold account",
run: () => this.run((row) => row.fold?.()),
},
{
group: GROUP,
keys: "o",
label: "Open row",
run: () => this.run((row) => row.open()),
},
{
group: GROUP,
keys: "x",
label: "Mark entry reviewed",
run: () => this.run((row) => row.review?.()),
},
],
id: "dashboard",
items: () => this.rows,
key: (row) => row.key,
});
private run(action: (row: Row) => void) {
if (this.nav.current) {
action(this.nav.current);
}
}
add(section: Section) {
this.sections = [...this.sections, section].sort(
(a, b) => a.order - b.order
);
return () => {
this.sections = this.sections.filter((s) => s !== section);
};
}
id(key: string) {
return this.nav.rowId(this.index.get(key) ?? -1);
}
point(key: string) {
this.nav.cursor = this.index.get(key) ?? -1;
}
rowClass(key: string) {
return this.nav.current?.key === key
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
: "hover:bg-row-hover";
}
}
export type { Column };
export function provideColumn() {
const column = new Column();
setContext(CONTEXT, column);
column.nav.use();
return column;
}
export function useColumn(order: number, rows: () => Row[]) {
const column = getContext<Column>(CONTEXT);
onMount(() => column.add({ order, rows }));
return column;
}
+137
View File
@@ -0,0 +1,137 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { ExchangeRate, Page, TransactionGroup } from "#lib/api/types.js";
import { formatMoney, toNumber } from "#lib/format.js";
import { session } from "#lib/session.svelte.js";
const KEY = "ffui.dashboard.in-primary";
const OWN_SAMPLE = 100;
const HOUR_MS = 3_600_000;
export interface Rate {
own: boolean;
rate: number;
}
export interface Part {
amount: number;
code: string;
}
export function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
class InPrimary {
on = $state(
typeof localStorage !== "undefined" && localStorage.getItem(KEY) === "1"
);
toggle() {
this.on = !this.on;
localStorage.setItem(KEY, this.on ? "1" : "0");
}
}
export const inPrimary = new InPrimary();
function ownRates(
groups: { attributes: TransactionGroup }[],
primary: string
): Record<string, Rate> {
const implied = new Map<string, number[]>();
const add = (code: string, rate: number) =>
implied.set(code, [...(implied.get(code) ?? []), rate]);
for (const s of groups.flatMap((g) => g.attributes.transactions)) {
const amount = toNumber(s.amount);
const foreign = toNumber(s.foreign_amount);
if (!(amount && foreign && s.foreign_currency_code && s.currency_code)) {
continue;
}
if (s.currency_code === primary) {
add(s.foreign_currency_code, amount / foreign);
} else if (s.foreign_currency_code === primary) {
add(s.currency_code, foreign / amount);
}
}
return Object.fromEntries(
[...implied].map(([code, values]) => [
code,
{ own: true, rate: median(values) },
])
);
}
function listedRates(
listed: { attributes: ExchangeRate }[],
primary: string
): Record<string, Rate> {
const rates: Record<string, Rate> = {};
const dated = new Map<string, string>();
for (const { attributes: r } of listed) {
const direct = r.to_currency_code === primary;
const code = direct ? r.from_currency_code : r.to_currency_code;
const rate = toNumber(r.rate);
const date = r.date ?? "";
const usable = direct || r.from_currency_code === primary;
if (code && rate && usable && date >= (dated.get(code) ?? "")) {
dated.set(code, date);
rates[code] = { own: false, rate: direct ? rate : 1 / rate };
}
}
return rates;
}
async function loadRates(primary: string): Promise<Record<string, Rate>> {
const [listed, own] = await Promise.all([
all<ExchangeRate>("/v1/exchange-rates"),
api<Page<TransactionGroup>>("/v1/search/transactions", {
query: { limit: OWN_SAMPLE, query: "has_foreign_amount:true" },
}),
]);
return {
...ownRates(own.data, primary),
...listedRates(listed, primary),
[primary]: { own: false, rate: 1 },
};
}
export const ratesQuery = () => ({
enabled: inPrimary.on,
queryFn: () => loadRates(session.primary?.code ?? ""),
queryKey: ["dashboard-rates", session.primary?.code ?? ""],
staleTime: HOUR_MS,
});
export function inPrimaryTotal(
parts: Part[],
rates: Record<string, Rate> | undefined
): number | null {
if (!(inPrimary.on && rates)) {
return null;
}
let total = 0;
for (const part of parts) {
const rate = rates[part.code];
if (!rate) {
return null;
}
total += part.amount * rate.rate;
}
return total;
}
export function formatPrimary(amount: number, signed = false): string {
const { primary } = session;
return `≈${formatMoney(
{
amount,
code: primary?.code,
decimals: primary?.decimal_places,
symbol: primary?.symbol,
},
signed
)}`;
}
+70
View File
@@ -0,0 +1,70 @@
const UNTIL_KEY = "ffui.reviewed-until";
const IDS_KEY = "ffui.reviewed-ids";
const FIRST_LOOK_MS = 2 * 86_400_000;
export interface Entry {
at: string;
id: string;
}
const time = (value: string) => Date.parse(value);
function read(key: string): string | null {
return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
}
class Reviewed {
until = $state(read(UNTIL_KEY) ?? "");
ids = $state.raw(new Set<string>(JSON.parse(read(IDS_KEY) ?? "[]")));
constructor() {
if (!this.until && typeof localStorage !== "undefined") {
const first = new Date(Date.now() - FIRST_LOOK_MS).toISOString();
this.save(first, this.ids);
}
}
isNew(entry: Entry) {
return time(entry.at) > time(this.until) && !this.ids.has(entry.id);
}
toggle(entry: Entry) {
const ids = new Set(this.ids);
if (this.isNew(entry)) {
ids.add(entry.id);
} else {
ids.delete(entry.id);
}
this.save(this.until, ids);
}
all(entries: Entry[]) {
const newest = Math.max(
time(this.until),
...entries.map((e) => time(e.at))
);
this.save(new Date(newest).toISOString(), new Set());
}
compact(entries: Entry[]) {
const after = entries
.filter((e) => time(e.at) > time(this.until))
.sort((a, b) => time(a.at) - time(b.at));
const open = after.find((e) => !this.ids.has(e.id));
const limit = open ? time(open.at) : Number.POSITIVE_INFINITY;
const done = after.filter((e) => time(e.at) < limit);
const ids = after
.filter((e) => time(e.at) >= limit && this.ids.has(e.id))
.map((e) => e.id);
this.save(done.at(-1)?.at ?? this.until, new Set(ids));
}
private save(until: string, ids: Set<string>) {
this.until = until;
this.ids = ids;
localStorage.setItem(UNTIL_KEY, until);
localStorage.setItem(IDS_KEY, JSON.stringify([...ids]));
}
}
export const reviewed = new Reviewed();
+59 -60
View File
@@ -1,93 +1,92 @@
<script lang="ts"> <script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import AccountBox from "#lib/components/dashboard/AccountBox.svelte"; import AccountsBlock from "#lib/components/dashboard/AccountsBlock.svelte";
import AccountsChart from "#lib/components/dashboard/AccountsChart.svelte"; import AccountsChart from "#lib/components/dashboard/AccountsChart.svelte";
import BudgetsChart from "#lib/components/dashboard/BudgetsChart.svelte"; import BudgetsChart from "#lib/components/dashboard/BudgetsChart.svelte";
import CategoriesChart from "#lib/components/dashboard/CategoriesChart.svelte"; import CategoriesChart from "#lib/components/dashboard/CategoriesChart.svelte";
import DebtsBox from "#lib/components/dashboard/DebtsBox.svelte"; import DebtsBox from "#lib/components/dashboard/DebtsBox.svelte";
import { import DueBox from "#lib/components/dashboard/DueBox.svelte";
accountsChartQuery, import { accountsQuery } from "#lib/components/dashboard/data.js";
accountsQuery, import InboxBox from "#lib/components/dashboard/InboxBox.svelte";
seriesPoints,
} from "#lib/components/dashboard/data.js";
import InfoBoxes from "#lib/components/dashboard/InfoBoxes.svelte"; import InfoBoxes from "#lib/components/dashboard/InfoBoxes.svelte";
import InsightBox from "#lib/components/dashboard/InsightBox.svelte"; import InsightBox from "#lib/components/dashboard/InsightBox.svelte";
import PaceBox from "#lib/components/dashboard/PaceBox.svelte";
import PiggyBanksBox from "#lib/components/dashboard/PiggyBanksBox.svelte"; import PiggyBanksBox from "#lib/components/dashboard/PiggyBanksBox.svelte";
import SubscriptionsBox from "#lib/components/dashboard/SubscriptionsBox.svelte"; import SubscriptionsBox from "#lib/components/dashboard/SubscriptionsBox.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte"; import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { today } from "#lib/dates.js"; import Checkbox from "#lib/components/ui/checkbox/checkbox.svelte";
import { provideColumn } from "#lib/dashboard/column.svelte.js";
import { inPrimary, ratesQuery } from "#lib/dashboard/primary.svelte.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js"; import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js"; import { session } from "#lib/session.svelte.js";
const assets = createQuery(() => ({ provideColumn();
...accountsQuery("asset", period.start, period.end),
placeholderData: keepPreviousData,
}));
const chart = createQuery(() => ({
...accountsChartQuery(period.start, period.end),
placeholderData: keepPreviousData,
}));
const frontpage = $derived.by(() => { const rates = createQuery(() => ratesQuery());
const list = (assets.data ?? []) const primary = $derived(session.primary?.code ?? "");
.filter((a) => a.attributes.active !== false) const ownRates = $derived(
.sort((a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0)); inPrimary.on && Object.values(rates.data ?? {}).some((r) => r.own)
if (!("frontpageAccounts" in session.prefs)) { );
return list;
}
const ids = new Set(session.frontpageAccounts);
return list.filter((a) => ids.has(a.id));
});
const trends = $derived( useKeys(() => [
new Map( {
(chart.data ?? []).map((set) => { group: "Dashboard",
const points = seriesPoints(set, false); keys: "$",
return [ label: `Show totals in ${primary}`,
set.label, run: () => inPrimary.toggle(),
{ },
marker: points.findIndex((p) => p.x === today()), ]);
values: points.map((p) => p.y),
}, const assets = createQuery(() =>
]; accountsQuery("asset", period.start, period.end)
})
)
); );
$effect(() => { $effect(() => {
if ( if (assets.isSuccess && assets.data.length === 0) {
assets.isSuccess &&
!assets.isPlaceholderData &&
assets.data.length === 0
) {
location.assign("/new-user"); location.assign("/new-user");
} }
}); });
</script> </script>
<PageHeader subtitle="What's playing?" title="Dashboard" /> <PageHeader subtitle="What's playing?" title="Dashboard">
{#snippet actions()}
<div class="flex items-center gap-1.5 text-[12px] text-cladd-fg-soft">
<Checkbox
checked={inPrimary.on}
id="in-primary"
onCheckedChange={() => inPrimary.toggle()}
/>
<label for="in-primary">Show in {primary}</label>
<span class="hidden sm:inline"><span class="kbd">$</span></span>
{#if ownRates}
<span>≈ from your own exchanges</span>
{/if}
</div>
{/snippet}
</PageHeader>
<InfoBoxes /> <InfoBoxes />
<div class="mt-3 grid gap-3 lg:grid-cols-12"> <div
<div class="flex min-w-0 flex-col gap-3 lg:col-span-8"> class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(25rem,36%)] xl:grid-rows-[auto_1fr]"
>
<PaceBox class="xl:col-start-1" />
<div
class="flex min-w-0 flex-col gap-3 xl:col-start-2 xl:row-span-2 xl:row-start-1"
>
<AccountsBlock />
<InboxBox />
<DueBox />
<DebtsBox />
<SubscriptionsBox />
<PiggyBanksBox />
</div>
<div class="flex min-w-0 flex-col gap-3 self-start xl:col-start-1">
<AccountsChart /> <AccountsChart />
<BudgetsChart /> <BudgetsChart />
<CategoriesChart /> <CategoriesChart />
</div> </div>
<div class="flex min-w-0 flex-col gap-3 lg:col-span-4">
{#each frontpage as account (account.id)}
{@const trend = trends.get(account.attributes.name)}
<AccountBox
{account}
marker={trend?.marker ?? -1}
trend={trend?.values ?? []}
/>
{/each}
<SubscriptionsBox />
<PiggyBanksBox />
<DebtsBox />
</div>
</div> </div>
<div class="mt-3 flex flex-col gap-3"> <div class="mt-3 flex flex-col gap-3">