147 lines
4.1 KiB
Svelte
147 lines
4.1 KiB
Svelte
<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 dark:text-(--ff-series-1) hover:underline"
|
|
href="/accounts/{kind}"
|
|
>See your {kind} accounts</a
|
|
>
|
|
{/snippet}
|
|
</Box>
|