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,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>