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

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

No files matched your search

@@ -0,0 +1,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>