feat: one page for a category, tag, budget or merchant
The page answers how much and whether that is normal: this month per currency with count, median and pace against a typical month, thirteen months as lanes per currency, and what is inside (merchants, cards, budgets, tags, subtags) on 1 to 4 with Enter narrowing the transactions below. Budgets keep spent, left and overspent with the limit line; expense and revenue accounts use the same page with visits and cadence. The twelve period cards and per-day charts are gone.
This commit is contained in:
1 parent
349a6a0b32
commit
722fdc2a20
19 files changed
+1654
-781
No files matched your search
@@ -19,6 +19,7 @@
|
||||
setPrimary,
|
||||
span,
|
||||
} from "#lib/components/dashboard/data.js";
|
||||
import Merchant from "#lib/components/dimension/Merchant.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||
@@ -75,9 +76,10 @@
|
||||
const statement = $derived(
|
||||
!everything && (type === "asset" || type === "liabilities")
|
||||
);
|
||||
const merchant = $derived(type === "expense" || type === "revenue");
|
||||
|
||||
const first = createQuery(() => ({
|
||||
enabled: everything,
|
||||
enabled: everything && !merchant,
|
||||
queryFn: async () => {
|
||||
const head = await api<Page<TransactionGroup>>(
|
||||
`/v1/accounts/${id}/transactions`,
|
||||
@@ -116,7 +118,7 @@
|
||||
return days <= 3000 ? "1M" : "1Y";
|
||||
});
|
||||
const chart = createQuery(() => ({
|
||||
enabled: !statement && (!everything || first.isSuccess),
|
||||
enabled: !(statement || merchant) && (!everything || first.isSuccess),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||
@@ -147,7 +149,7 @@
|
||||
Number(page.url.searchParams.get("page") ?? "1") || 1
|
||||
);
|
||||
const dated = createQuery(() => ({
|
||||
enabled: !(everything || statement),
|
||||
enabled: !(everything || statement || merchant),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(
|
||||
@@ -161,7 +163,7 @@
|
||||
queryKey: ["account-tx-all", id, period.start, period.end],
|
||||
}));
|
||||
const paged = createQuery(() => ({
|
||||
enabled: everything,
|
||||
enabled: everything && !merchant,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
||||
@@ -310,6 +312,8 @@
|
||||
{#if item}
|
||||
<Statement {item} />
|
||||
{/if}
|
||||
{:else if merchant}
|
||||
<Merchant {everything} {id} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
|
||||
@@ -1,41 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate } from "#lib/api/lists.js";
|
||||
import type {
|
||||
Budget,
|
||||
BudgetLimit,
|
||||
One,
|
||||
TransactionGroup,
|
||||
} from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import type { Budget, BudgetLimit, One } from "#lib/api/types.js";
|
||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
|
||||
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
|
||||
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
|
||||
import {
|
||||
breakdown,
|
||||
curOfSplit,
|
||||
dayOf,
|
||||
firstDay,
|
||||
fromEntries,
|
||||
inRange,
|
||||
periodRanges,
|
||||
rows as splitRows,
|
||||
stepLabel,
|
||||
} from "#lib/components/categories/sums.js";
|
||||
import { tones, wash } from "#lib/components/charts/chart.js";
|
||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||
import Meter from "#lib/components/charts/Meter.svelte";
|
||||
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||
import type { Lane, StackRow } from "#lib/components/charts/chart.js";
|
||||
import Dimension from "#lib/components/dimension/Dimension.svelte";
|
||||
import LimitLine from "#lib/components/dimension/LimitLine.svelte";
|
||||
import LimitStrip from "#lib/components/dimension/LimitStrip.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { addDays, contains, today } from "#lib/dates.js";
|
||||
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
|
||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import { budgetSpec } from "#lib/dimension/spec.js";
|
||||
import { limitFacts, stripsOf, withLimits } from "#lib/dimension/stats.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { longMonth } from "#lib/reports/board.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { limitRange } from "./data.js";
|
||||
|
||||
let { id, limitId }: { id: string; limitId?: string } = $props();
|
||||
@@ -48,166 +30,42 @@
|
||||
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
|
||||
queryKey: ["budget-limits", id],
|
||||
}));
|
||||
const groups = createQuery(() => ({
|
||||
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
|
||||
queryKey: ["budget-transactions", id],
|
||||
}));
|
||||
const model = new DimensionModel(() => ({ spec: budgetSpec(id) }));
|
||||
|
||||
const name = $derived(budget.data?.data.attributes.name ?? "");
|
||||
const sortedLimits = $derived(
|
||||
[...(limits.data ?? [])].sort((a, b) =>
|
||||
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
|
||||
const list = $derived(limits.data ?? []);
|
||||
const limit = $derived(list.find((l) => l.id === limitId));
|
||||
const facts = $derived(
|
||||
model.range
|
||||
? limitFacts(list, model.main, model.range, model.now).sort(
|
||||
(a, b) =>
|
||||
Number(b.code === limit?.attributes.currency_code) -
|
||||
Number(a.code === limit?.attributes.currency_code)
|
||||
)
|
||||
: []
|
||||
);
|
||||
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
|
||||
const range = $derived(limit ? limitRange(limit) : null);
|
||||
const strips = $derived(stripsOf(list, model.main, model.months));
|
||||
|
||||
const allRows = $derived(
|
||||
splitRows(groups.data ?? [], (s) => s.budget_id === id)
|
||||
);
|
||||
const scoped = $derived(
|
||||
limit && range
|
||||
? inRange(allRows, range).filter(
|
||||
(r) => r.split.currency_code === limit.attributes.currency_code
|
||||
)
|
||||
: allRows
|
||||
);
|
||||
const spentRows = $derived(
|
||||
scoped.filter((r) => r.split.type === "withdrawal")
|
||||
);
|
||||
const codes = $derived(
|
||||
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
|
||||
Boolean
|
||||
)
|
||||
);
|
||||
const code = $derived(
|
||||
limit?.attributes.currency_code ??
|
||||
(codes.includes(page.url.searchParams.get("currency") ?? "")
|
||||
? page.url.searchParams.get("currency")
|
||||
: codes[0]) ??
|
||||
""
|
||||
);
|
||||
const inCur = $derived(
|
||||
spentRows.filter((r) => r.split.currency_code === code)
|
||||
);
|
||||
const cur = $derived(
|
||||
inCur[0]
|
||||
? curOfSplit(inCur[0].split)
|
||||
: {
|
||||
code,
|
||||
decimals: limit?.attributes.currency_decimal_places ?? 2,
|
||||
symbol: limit?.attributes.currency_symbol ?? code,
|
||||
$effect(() => {
|
||||
if (limit) {
|
||||
const range = limitRange(limit);
|
||||
untrack(() => period.set(range));
|
||||
}
|
||||
);
|
||||
const fmt = (value: number) =>
|
||||
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||
|
||||
const visibleGroups = $derived.by(() => {
|
||||
if (!range) {
|
||||
return groups.data ?? [];
|
||||
}
|
||||
const ids = new Set(scoped.map((r) => r.group.id));
|
||||
return (groups.data ?? []).filter((g) => ids.has(g.id));
|
||||
});
|
||||
|
||||
const view = $derived(period.view);
|
||||
const history = $derived.by(() => {
|
||||
const first = firstDay(inCur) ?? period.start;
|
||||
const end = period.end > first ? period.end : today();
|
||||
const ranges = periodRanges(end, view, 400)
|
||||
.filter((r) => r.end >= first)
|
||||
.reverse();
|
||||
return ranges.map((r) => {
|
||||
const spent = inRange(inCur, r).reduce(
|
||||
(sum, row) => sum + toNumber(row.split.amount),
|
||||
0
|
||||
$effect(() => {
|
||||
const { start, end } = period;
|
||||
untrack(() => {
|
||||
const range = limit && limitRange(limit);
|
||||
if (range && (range.start !== start || range.end !== end)) {
|
||||
setTimeout(() =>
|
||||
goto(`/budgets/show/${id}?start=${start}&end=${end}`, {
|
||||
replace: true,
|
||||
reset: false,
|
||||
})
|
||||
);
|
||||
const own = sortedLimits.find(
|
||||
(l) =>
|
||||
l.attributes.currency_code === code &&
|
||||
contains(r, l.attributes.start ?? "")
|
||||
);
|
||||
const amount = own ? toNumber(own.attributes.amount) : 0;
|
||||
return {
|
||||
format: fmt,
|
||||
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
|
||||
key: r.start,
|
||||
label: stepLabel(r, view),
|
||||
values: amount
|
||||
? {
|
||||
left: Math.max(0, amount - spent),
|
||||
over: Math.max(0, spent - amount),
|
||||
spent: Math.min(spent, amount),
|
||||
}
|
||||
: { left: 0, over: 0, spent },
|
||||
};
|
||||
});
|
||||
});
|
||||
const historyKeys = [
|
||||
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
|
||||
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
|
||||
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||
];
|
||||
|
||||
const burn = $derived.by(() => {
|
||||
if (!(limit && range)) {
|
||||
return [];
|
||||
}
|
||||
const amount = toNumber(limit.attributes.amount);
|
||||
const last = range.end < today() ? range.end : today();
|
||||
const byDay = new Map<string, number>();
|
||||
for (const row of inCur) {
|
||||
byDay.set(
|
||||
dayOf(row),
|
||||
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
|
||||
);
|
||||
}
|
||||
const points: { x: string; y: number }[] = [];
|
||||
let left = amount;
|
||||
for (let d = range.start; d <= last; d = addDays(d, 1)) {
|
||||
left -= byDay.get(d) ?? 0;
|
||||
points.push({ x: d, y: left });
|
||||
}
|
||||
return [
|
||||
{
|
||||
area: true,
|
||||
format: fmt,
|
||||
key: "left",
|
||||
label: "Left",
|
||||
points,
|
||||
strong: true,
|
||||
},
|
||||
{
|
||||
color: "var(--cladd-fg-softer)",
|
||||
format: fmt,
|
||||
key: "pace",
|
||||
label: "Even pace",
|
||||
points: [
|
||||
{ x: range.start, y: amount },
|
||||
{ x: range.end, y: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const byCategory = $derived(
|
||||
breakdown(inCur, (s) => ({
|
||||
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
|
||||
label: s.category_name ?? "(no category)",
|
||||
}))
|
||||
);
|
||||
const bySource = $derived(
|
||||
breakdown(inCur, (s) => ({
|
||||
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
||||
label: s.source_name ?? "",
|
||||
}))
|
||||
);
|
||||
const byDestination = $derived(
|
||||
breakdown(inCur, (s) => ({
|
||||
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
||||
label: s.destination_name ?? "",
|
||||
}))
|
||||
);
|
||||
|
||||
async function remove() {
|
||||
await api(`/v1/budgets/${id}`, { method: "DELETE" });
|
||||
@@ -227,16 +85,14 @@
|
||||
|
||||
<PageHeader
|
||||
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
|
||||
subtitle={limit && range
|
||||
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
|
||||
: undefined}
|
||||
subtitle={period.label}
|
||||
title={name || "Budget"}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
href="/budgets/edit/{id}"
|
||||
size="sm"
|
||||
title="Edit budget (E)"
|
||||
title="Shift+E"
|
||||
variant="outline"
|
||||
>Edit</Button
|
||||
>
|
||||
@@ -244,111 +100,50 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if limit && range}
|
||||
<Box
|
||||
title="Chart for all transactions for budget "{name}" between {formatRange(
|
||||
range.start,
|
||||
range.end
|
||||
)} in {cur.code}"
|
||||
>
|
||||
{#if burn[0]?.points.length}
|
||||
<LineChart
|
||||
label="Amount left in this budget"
|
||||
marker={contains(range, today()) ? today() : undefined}
|
||||
series={burn}
|
||||
zero
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
|
||||
{/if}
|
||||
</Box>
|
||||
{:else}
|
||||
<Box title="Chart of all transactions for budget "{name}"">
|
||||
{#snippet tools()}
|
||||
<CurrencyTabs {codes} current={code} />
|
||||
{/snippet}
|
||||
{#if history.length > 0}
|
||||
<StackedBars
|
||||
format={fmt}
|
||||
keys={historyKeys}
|
||||
label="Spent per period against the budgeted amount"
|
||||
rows={history}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
|
||||
{/if}
|
||||
</Box>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<BreakdownBox items={byCategory} title="Expenses by category" />
|
||||
<BreakdownBox items={bySource} title="Expenses by asset account" />
|
||||
<BreakdownBox
|
||||
items={byDestination}
|
||||
title="Expenses by destination account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<GroupsBox
|
||||
groups={visibleGroups}
|
||||
loading={groups.isPending}
|
||||
title="Transactions"
|
||||
>
|
||||
{#snippet links()}
|
||||
{#if limit}
|
||||
<a class="text-brand hover:underline" href="/budgets/show/{id}"
|
||||
>Show everything</a
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</GroupsBox>
|
||||
<nav
|
||||
aria-label="Budgeted amounts"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
|
||||
>
|
||||
{#each sortedLimits as item (item.id)}
|
||||
{@const r = limitRange(item)}
|
||||
{@const amount = toNumber(item.attributes.amount)}
|
||||
{@const spent = -(
|
||||
fromEntries(item.attributes.spent).find(
|
||||
(s) => s.code === item.attributes.currency_code
|
||||
)?.amount ?? 0
|
||||
{#snippet header(
|
||||
lane: Lane,
|
||||
row: StackRow | undefined
|
||||
)}
|
||||
{@const money = (value: number) =>
|
||||
formatMoney({
|
||||
amount: value,
|
||||
decimals: item.attributes.currency_decimal_places,
|
||||
symbol: item.attributes.currency_symbol,
|
||||
})}
|
||||
<a
|
||||
aria-current={item.id === limitId ? "page" : undefined}
|
||||
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
|
||||
limitId
|
||||
? "bg-row-cursor outline-2 outline-brand"
|
||||
: "hover:bg-row-hover"}"
|
||||
href="/budgets/show/{id}/{item.id}"
|
||||
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
|
||||
{#if row}
|
||||
<span class="text-cladd-fg-soft">{longMonth(row.key)}</span>
|
||||
<span class="num font-semibold text-cladd-fg"
|
||||
>{lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))}</span
|
||||
>
|
||||
<span class="font-semibold text-cladd-fg"
|
||||
>{formatRange(r.start, r.end)}</span
|
||||
{#if row.values.left !== undefined}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>of
|
||||
<span class="num"
|
||||
>{lane.format((row.values.spent ?? 0) + row.values.left)}</span
|
||||
>
|
||||
<span class="flex justify-between gap-2">
|
||||
<span class="num text-expense">{money(spent)}</span>
|
||||
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
|
||||
limit</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if lane.typical}
|
||||
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
|
||||
></span>
|
||||
typical
|
||||
<span class="num">{lane.format(lane.typical)}</span>
|
||||
</span>
|
||||
<Meter
|
||||
height={6}
|
||||
max={amount}
|
||||
segments={spent > amount
|
||||
? [
|
||||
{ color: tones.warn, value: amount },
|
||||
{ color: tones.expense, value: spent - amount },
|
||||
]
|
||||
: [{ color: "var(--ff-brand)", value: spent }]}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Dimension
|
||||
{header}
|
||||
{model}
|
||||
noun="purchases"
|
||||
shape={(lanes) => withLimits(lanes, list)}
|
||||
>
|
||||
{#snippet extra()}
|
||||
{#if !limits.isPending}
|
||||
<LimitLine {facts} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet below()}
|
||||
<LimitStrip budgetId={id} cursor={model.focus} {strips} />
|
||||
{/snippet}
|
||||
</Dimension>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import type { Breakdown } from "./sums.js";
|
||||
|
||||
const TOP = 8;
|
||||
|
||||
let {
|
||||
title,
|
||||
items,
|
||||
color = "var(--ff-expense)",
|
||||
}: { title: string; items: Breakdown[]; color?: string } = $props();
|
||||
|
||||
function bars(item: Breakdown) {
|
||||
const top = item.bars.slice(0, TOP);
|
||||
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
|
||||
const format = (value: number) =>
|
||||
formatMoney({
|
||||
amount: value,
|
||||
decimals: item.cur.decimals,
|
||||
symbol: item.cur.symbol,
|
||||
});
|
||||
const list = top.map((bar) => ({
|
||||
format,
|
||||
href: bar.href,
|
||||
key: bar.label,
|
||||
label: bar.label,
|
||||
value: bar.amount,
|
||||
}));
|
||||
if (rest > 0) {
|
||||
list.push({
|
||||
format,
|
||||
href: undefined,
|
||||
key: "\u0000other",
|
||||
label: `${item.bars.length - TOP} more`,
|
||||
value: rest,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Box {title}>
|
||||
{#if items.length === 0}
|
||||
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each items as item (item.cur.code)}
|
||||
{#if items.length > 1}
|
||||
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
|
||||
{item.cur.code}
|
||||
</h3>
|
||||
{/if}
|
||||
<BarChart
|
||||
bars={bars(item)}
|
||||
{color}
|
||||
label={title}
|
||||
labelWidth="minmax(5rem, 10rem)"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Box>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
|
||||
let { codes, current }: { codes: string[]; current: string } = $props();
|
||||
|
||||
function href(code: string) {
|
||||
const url = new URL(page.url.href);
|
||||
url.searchParams.set("currency", code);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if codes.length > 1}
|
||||
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
|
||||
{#each codes as code (code)}
|
||||
<a
|
||||
aria-current={code === current ? "true" : undefined}
|
||||
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
|
||||
? "bg-row-selected font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:bg-row-hover"}"
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-replacestate
|
||||
href={href(code)}
|
||||
>{code}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
@@ -1,32 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate } from "#lib/api/lists.js";
|
||||
import type { Category, One, TransactionGroup } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { invalidate } from "#lib/api/lists.js";
|
||||
import type { Category, One } from "#lib/api/types.js";
|
||||
import {
|
||||
periodPath,
|
||||
usePeriodPath,
|
||||
} from "#lib/components/budgets/period-path.svelte.js";
|
||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||
import Dimension from "#lib/components/dimension/Dimension.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { formatDate, formatMoney } from "#lib/format.js";
|
||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import { categorySpec } from "#lib/dimension/spec.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import CurrencyTabs from "./CurrencyTabs.svelte";
|
||||
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
|
||||
import PeriodList from "./PeriodList.svelte";
|
||||
import {
|
||||
firstDay,
|
||||
periodRanges,
|
||||
rows as splitRows,
|
||||
stepLabel,
|
||||
stepRanges,
|
||||
} from "./sums.js";
|
||||
|
||||
let { id, everything = false }: { id: string; everything?: boolean } =
|
||||
$props();
|
||||
@@ -38,49 +27,12 @@
|
||||
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
|
||||
queryKey: ["category", id],
|
||||
}));
|
||||
const groups = createQuery(() => ({
|
||||
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
|
||||
queryKey: ["category-transactions", id],
|
||||
}));
|
||||
|
||||
const name = $derived(category.data?.data.attributes.name ?? "");
|
||||
const own = $derived(
|
||||
splitRows(groups.data ?? [], (s) => s.category_id === id)
|
||||
);
|
||||
const curs = $derived(currencies(own));
|
||||
const cur = $derived(
|
||||
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
|
||||
curs[0]
|
||||
);
|
||||
const fmt = $derived((value: number) =>
|
||||
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
|
||||
);
|
||||
|
||||
const periodRows = $derived(
|
||||
cur
|
||||
? flowRows(own, stepRanges(period.range), cur, (r) =>
|
||||
r.start === r.end
|
||||
? String(Number(r.start.slice(8)))
|
||||
: formatDate(r.start, "short")
|
||||
)
|
||||
: []
|
||||
);
|
||||
const historyRows = $derived.by(() => {
|
||||
if (!cur) {
|
||||
return [];
|
||||
}
|
||||
const first = firstDay(own) ?? period.start;
|
||||
const ranges = periodRanges(period.end, period.view, 400)
|
||||
.filter((r) => r.end >= first)
|
||||
.reverse();
|
||||
return flowRows(
|
||||
own,
|
||||
ranges,
|
||||
cur,
|
||||
(r) => stepLabel(r, period.view),
|
||||
(r) => periodPath(base, r.start, r.end)
|
||||
);
|
||||
});
|
||||
const model = new DimensionModel(() => ({
|
||||
everything,
|
||||
spec: categorySpec(id),
|
||||
}));
|
||||
const other = $derived(everything ? base : `${base}/all`);
|
||||
|
||||
async function remove() {
|
||||
await api(`/v1/categories/${id}`, { method: "DELETE" });
|
||||
@@ -95,6 +47,12 @@
|
||||
label: "Edit category",
|
||||
run: () => goto(`/categories/edit/${id}`),
|
||||
},
|
||||
{
|
||||
group: "Category",
|
||||
keys: "A",
|
||||
label: everything ? "Show the current period" : "Show all time",
|
||||
run: () => goto(other),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -103,16 +61,17 @@
|
||||
{ href: "/categories", label: "Categories" },
|
||||
{ label: name || "Category" },
|
||||
]}
|
||||
subtitle={everything
|
||||
? "All transactions"
|
||||
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
|
||||
subtitle={everything ? "All time" : period.label}
|
||||
title={name || "Category"}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href={other} size="sm" title="Shift+A" variant="outline"
|
||||
>{everything ? "This period" : "All time"}</Button
|
||||
>
|
||||
<Button
|
||||
href="/categories/edit/{id}"
|
||||
size="sm"
|
||||
title="Edit category (E)"
|
||||
title="Shift+E"
|
||||
variant="outline"
|
||||
>Edit</Button
|
||||
>
|
||||
@@ -120,66 +79,11 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
|
||||
{#if !everything}
|
||||
<Box
|
||||
title="Chart for all transactions for category "{name}" between {formatDate(
|
||||
period.start
|
||||
)} and {formatDate(period.end)}"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<CurrencyTabs
|
||||
codes={curs.map((c) => c.code)}
|
||||
current={cur?.code ?? ""}
|
||||
<Dimension
|
||||
{model}
|
||||
noun={model.dir === -1 ? "purchases" : "payments"}
|
||||
onmonth={(range) =>
|
||||
everything
|
||||
? goto(periodPath(base, range.start, range.end))
|
||||
: period.set(range)}
|
||||
/>
|
||||
{/snippet}
|
||||
<StackedBars
|
||||
format={fmt}
|
||||
height={220}
|
||||
keys={FLOW_KEYS}
|
||||
label="Spent and earned in this period"
|
||||
rows={periodRows}
|
||||
/>
|
||||
</Box>
|
||||
{/if}
|
||||
<Box title="Chart for all transactions for category "{name}"">
|
||||
{#snippet tools()}
|
||||
{#if everything}
|
||||
<CurrencyTabs
|
||||
codes={curs.map((c) => c.code)}
|
||||
current={cur?.code ?? ""}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<StackedBars
|
||||
format={fmt}
|
||||
height={220}
|
||||
keys={FLOW_KEYS}
|
||||
label="Spent and earned per period"
|
||||
rows={historyRows}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<PeriodList
|
||||
groups={groups.data ?? []}
|
||||
href={(r) => periodPath(base, r.start, r.end)}
|
||||
keep={(s) => s.category_id === id}
|
||||
loading={groups.isPending}
|
||||
sidebar={!everything}
|
||||
title="Transactions"
|
||||
>
|
||||
{#snippet links()}
|
||||
{#if everything}
|
||||
<a class="text-brand hover:underline" href={base}
|
||||
>Show the current period and overview</a
|
||||
>
|
||||
{:else}
|
||||
<a class="text-brand hover:underline" href="{base}/all"
|
||||
>Show all transactions without grouping them by date</a
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PeriodList>
|
||||
</div>
|
||||
@@ -1,56 +0,0 @@
|
||||
import {
|
||||
type StackKey,
|
||||
type StackRow,
|
||||
tones,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import type { Range } from "#lib/dates.js";
|
||||
import { formatMoney, toNumber } from "#lib/format.js";
|
||||
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
|
||||
|
||||
export const FLOW_KEYS: StackKey[] = [
|
||||
{ color: tones.income, key: "earned", label: "Earned" },
|
||||
{ color: tones.expense, key: "spent", label: "Spent" },
|
||||
];
|
||||
|
||||
export function currencies(items: Row[]): Cur[] {
|
||||
const found = new Map<string, Cur>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const { split } of items) {
|
||||
const cur = curOfSplit(split);
|
||||
found.set(cur.code, cur);
|
||||
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
|
||||
}
|
||||
return [...found.values()].sort(
|
||||
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function flowRows(
|
||||
items: Row[],
|
||||
ranges: Range[],
|
||||
cur: Cur,
|
||||
label: (range: Range) => string,
|
||||
href?: (range: Range) => string
|
||||
): StackRow[] {
|
||||
const own = items.filter((row) => row.split.currency_code === cur.code);
|
||||
const format = (value: number) =>
|
||||
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||
return ranges.map((range) => {
|
||||
let spent = 0;
|
||||
let earned = 0;
|
||||
for (const { split } of inRange(own, range)) {
|
||||
if (split.type === "withdrawal") {
|
||||
spent -= toNumber(split.amount);
|
||||
} else if (split.type === "deposit") {
|
||||
earned += toNumber(split.amount);
|
||||
}
|
||||
}
|
||||
return {
|
||||
format,
|
||||
href: href?.(range),
|
||||
key: range.start,
|
||||
label: label(range),
|
||||
values: { earned, spent },
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
title = (row: StackRow) => row.label,
|
||||
label = "Months by currency",
|
||||
legend = true,
|
||||
moreIsGood = false,
|
||||
header,
|
||||
onselect,
|
||||
}: {
|
||||
@@ -38,6 +39,7 @@
|
||||
partial?: string | null;
|
||||
pinned?: boolean;
|
||||
dotLabel?: string;
|
||||
moreIsGood?: boolean;
|
||||
dotColor?: string;
|
||||
title?: (row: StackRow) => string;
|
||||
label?: string;
|
||||
@@ -94,7 +96,7 @@
|
||||
if (ratio === null || Math.abs(ratio) < 0.03) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return ratio > 0 ? "text-expense" : "text-income";
|
||||
return ratio > 0 === moreIsGood ? "text-income" : "text-expense";
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { setParam } from "#lib/components/categories/url.js";
|
||||
import {
|
||||
type Lane,
|
||||
type StackRow,
|
||||
tones,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import Lanes from "#lib/components/charts/Lanes.svelte";
|
||||
import type { Range } from "#lib/dates.js";
|
||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import type { Facet } from "#lib/dimension/spec.js";
|
||||
import { lanesOf } from "#lib/dimension/stats.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { longMonth, monthRange } from "#lib/reports/board.js";
|
||||
import { page } from "$app/state";
|
||||
import Headline from "./Headline.svelte";
|
||||
import Inside from "./Inside.svelte";
|
||||
import Ledger from "./Ledger.svelte";
|
||||
|
||||
const LANE_HEIGHTS = [180, 112, 88];
|
||||
|
||||
let {
|
||||
model,
|
||||
noun,
|
||||
accountId,
|
||||
shape = (list: Lane[]) => list,
|
||||
onmonth = (month: Range) => period.set(month),
|
||||
lead,
|
||||
extra,
|
||||
below,
|
||||
header,
|
||||
}: {
|
||||
model: DimensionModel;
|
||||
noun: string;
|
||||
accountId?: string;
|
||||
shape?: (lanes: Lane[]) => Lane[];
|
||||
onmonth?: (range: Range) => void;
|
||||
lead?: Snippet;
|
||||
extra?: Snippet;
|
||||
below?: Snippet;
|
||||
header?: Snippet<[Lane, StackRow | undefined]>;
|
||||
} = $props();
|
||||
|
||||
const height = $derived(
|
||||
LANE_HEIGHTS[Math.min(model.codes.length, LANE_HEIGHTS.length) - 1] ?? 88
|
||||
);
|
||||
const lanes = $derived(
|
||||
shape(
|
||||
lanesOf(
|
||||
model.flows,
|
||||
model.months,
|
||||
model.codes,
|
||||
model.dir,
|
||||
model.norms,
|
||||
height
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const facets = $derived(model.spec.facets);
|
||||
const facet = $derived(
|
||||
facets.find((f) => f.id === page.url.searchParams.get("facet")) ?? facets[0]
|
||||
);
|
||||
const only = $derived.by(() => {
|
||||
const value = page.url.searchParams.get("only") ?? "";
|
||||
const cut = value.indexOf(":");
|
||||
const found = facets.find((f) => f.id === value.slice(0, cut));
|
||||
return found && cut > 0
|
||||
? { facet: found, key: value.slice(cut + 1) }
|
||||
: null;
|
||||
});
|
||||
const narrowed = $derived.by(() => {
|
||||
if (!only) {
|
||||
return null;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
let label = only.key;
|
||||
for (const flow of model.periodFlows) {
|
||||
const key = only.facet.of(flow).find((k) => k.key === only.key);
|
||||
if (key) {
|
||||
ids.add(flow.group);
|
||||
({ label } = key);
|
||||
}
|
||||
}
|
||||
return { groups: model.groups.filter((g) => ids.has(g.id)), label };
|
||||
});
|
||||
|
||||
const pick = (f: Facet) =>
|
||||
setParam("facet", f.id === facets[0]?.id ? "" : f.id);
|
||||
const clear = () => setParam("only", "");
|
||||
|
||||
useKeys(() =>
|
||||
only
|
||||
? [
|
||||
{
|
||||
group: "Inside",
|
||||
keys: "Escape",
|
||||
label: "Show all transactions again",
|
||||
run: clear,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<Headline {lead} {model} {noun} />
|
||||
{@render extra?.()}
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
|
||||
<Box title={model.everything ? "All months" : "13 months"}>
|
||||
{#if lanes.length > 0}
|
||||
<Lanes
|
||||
cursor={model.focus}
|
||||
dotColor={model.dir === -1 ? tones.income : tones.expense}
|
||||
dotLabel={model.dir === -1 ? "in" : "out"}
|
||||
{header}
|
||||
label="Months by currency"
|
||||
{lanes}
|
||||
moreIsGood={model.dir === 1}
|
||||
onselect={(month) => onmonth(monthRange(month))}
|
||||
partial={model.partial}
|
||||
title={(row) => longMonth(row.key)}
|
||||
/>
|
||||
{:else if !model.loading}
|
||||
<p class="py-10 text-center text-[12px] text-cladd-fg-soft">
|
||||
No spending or income here in these months.
|
||||
</p>
|
||||
{/if}
|
||||
{@render below?.()}
|
||||
</Box>
|
||||
<Inside
|
||||
class="lg:[contain:size]"
|
||||
current={only?.facet.id === facet.id ? only.key : null}
|
||||
{facet}
|
||||
{model}
|
||||
onfacet={pick}
|
||||
onnarrow={(f, row) =>
|
||||
setParam(
|
||||
"only",
|
||||
only?.facet.id === f.id && only.key === row.key
|
||||
? ""
|
||||
: `${f.id}:${row.key}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Ledger
|
||||
{accountId}
|
||||
empty={model.everything
|
||||
? "No transactions."
|
||||
: "No transactions in this period."}
|
||||
groups={model.groups}
|
||||
loading={model.loading}
|
||||
{narrowed}
|
||||
onclear={clear}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
|
||||
import { untrack } from "svelte";
|
||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||
import { type FacetRow, fmt } from "#lib/dimension/stats.js";
|
||||
import { percent } from "#lib/format.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const GROUP = "Inside";
|
||||
|
||||
let {
|
||||
rows,
|
||||
loading,
|
||||
active,
|
||||
current,
|
||||
marker,
|
||||
label,
|
||||
onnarrow,
|
||||
onleave,
|
||||
}: {
|
||||
rows: FacetRow[];
|
||||
loading: boolean;
|
||||
active: boolean;
|
||||
current: string | null;
|
||||
marker: number;
|
||||
label: string;
|
||||
onnarrow: (row: FacetRow) => void;
|
||||
onleave: () => void;
|
||||
} = $props();
|
||||
|
||||
function tint(i: number, row: FacetRow) {
|
||||
if (active && nav.cursor === i) {
|
||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
||||
}
|
||||
return row.key === current ? "bg-row-selected" : "hover:bg-row-hover";
|
||||
}
|
||||
|
||||
const top = $derived(Math.max(...rows.map((row) => row.share), 0) || 1);
|
||||
|
||||
const nav: ListNav<FacetRow> = new ListNav<FacetRow>({
|
||||
extra: () => [
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "Enter",
|
||||
label: "Narrow the transactions to this row",
|
||||
run: () => nav.current && onnarrow(nav.current),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "o",
|
||||
label: "Open the page of this row",
|
||||
run: () => nav.current?.href && goto(nav.current.href),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "Escape",
|
||||
label: "Back to the transactions",
|
||||
run: onleave,
|
||||
},
|
||||
],
|
||||
id: "facet",
|
||||
items: () => rows,
|
||||
key: (row) => row.key,
|
||||
});
|
||||
|
||||
useKeys(() => (active ? nav.bindings() : []));
|
||||
|
||||
$effect(() => {
|
||||
if (active) {
|
||||
untrack(() =>
|
||||
nav.move(
|
||||
Math.max(
|
||||
0,
|
||||
rows.findIndex((row) => row.key === current)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if rows.length === 0 && !loading}
|
||||
<p class="px-3 py-6 text-center text-[12px] text-cladd-fg-soft">
|
||||
Nothing in this period.
|
||||
</p>
|
||||
{:else if rows.length > 0}
|
||||
<ul
|
||||
aria-label={label}
|
||||
class="grid min-h-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)_auto_auto_auto] overflow-y-auto py-1 text-[12px] sm:grid-cols-[minmax(6rem,1fr)_minmax(2rem,7rem)_auto_auto_auto_auto]"
|
||||
>
|
||||
{#each rows as row, i (row.key)}
|
||||
<li
|
||||
class="col-span-full grid h-7 grid-cols-subgrid items-center gap-x-2.5 px-3 {tint(
|
||||
i,
|
||||
row
|
||||
)}"
|
||||
id={nav.rowId(i)}
|
||||
>
|
||||
<button
|
||||
class="truncate text-left text-cladd-fg hover:underline"
|
||||
onclick={() => onnarrow(row)}
|
||||
title={row.label}
|
||||
type="button"
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
<span class="relative h-2.5 max-sm:hidden">
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 rounded-r-sm bg-brand"
|
||||
style:width="max(1px, {(row.share / top) * 100}%)"
|
||||
></span>
|
||||
</span>
|
||||
<span class="num truncate text-right text-cladd-fg">
|
||||
{row.sums.map((sum) => fmt(sum.code, sum.amount, 0)).join(" + ")}
|
||||
</span>
|
||||
<span class="num w-9 text-right text-cladd-fg-soft"
|
||||
>{percent(row.share)}</span
|
||||
>
|
||||
<span class="max-sm:hidden">
|
||||
<Sparkline
|
||||
color="var(--ff-brand)"
|
||||
height={18}
|
||||
{marker}
|
||||
values={row.spark}
|
||||
width={56}
|
||||
/>
|
||||
</span>
|
||||
{#if row.href}
|
||||
<a
|
||||
aria-label="Open {row.label}"
|
||||
class="text-cladd-fg-softer hover:text-cladd-fg"
|
||||
href={row.href}
|
||||
>
|
||||
<ArrowUpRight class="size-3.5" />
|
||||
</a>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { daysBetween } from "#lib/dates.js";
|
||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import { counted, fmt, type Money } from "#lib/dimension/stats.js";
|
||||
import { formatDate, formatNumber } from "#lib/format.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { monthOf } from "#lib/reports/board.js";
|
||||
import { versusText } from "#lib/reports/typical.js";
|
||||
|
||||
const WEEK = 7;
|
||||
const SAME = 0.03;
|
||||
|
||||
let {
|
||||
model,
|
||||
noun,
|
||||
lead,
|
||||
}: { model: DimensionModel; noun: string; lead?: Snippet } = $props();
|
||||
|
||||
const facts = $derived(model.facts);
|
||||
const when = $derived.by(() => {
|
||||
if (!model.range) {
|
||||
return "all time";
|
||||
}
|
||||
if (model.focus === monthOf(model.now) && period.isCurrent) {
|
||||
return "this month";
|
||||
}
|
||||
return `in ${period.label}`;
|
||||
});
|
||||
const days = $derived.by(() => {
|
||||
const start = model.range?.start ?? model.history.first;
|
||||
const end =
|
||||
model.range && model.range.end < model.now ? model.range.end : model.now;
|
||||
return start ? daysBetween(start, end) + 1 : 0;
|
||||
});
|
||||
const perWeek = $derived(days >= WEEK ? (facts.count * WEEK) / days : null);
|
||||
const main = $derived(model.norms[0]);
|
||||
const typicals = $derived(model.norms.filter((n) => n.typical > 0));
|
||||
const active = $derived(
|
||||
model.typicalMonths.filter((month) =>
|
||||
model.main.some((flow) => monthOf(flow.date) === month)
|
||||
).length
|
||||
);
|
||||
const good = $derived(model.dir === 1);
|
||||
|
||||
function tone(ratio: number | null) {
|
||||
if (ratio === null || Math.abs(ratio) < SAME) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return ratio > 0 === good ? "text-income" : "text-expense";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet amounts(
|
||||
sums: Money[],
|
||||
big: boolean
|
||||
)}
|
||||
{#each sums as sum, i (sum.code)}
|
||||
{#if i > 0}
|
||||
<span class="text-cladd-fg-soft">+</span>
|
||||
{/if}
|
||||
<span class="num font-semibold text-cladd-fg {big ? "text-[22px]" : ""}"
|
||||
>{fmt(sum.code, sum.amount, 0)}</span
|
||||
>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-1 pb-3">
|
||||
{#if lead}
|
||||
{@render lead()}
|
||||
{:else if !model.loading}
|
||||
<p
|
||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
|
||||
>
|
||||
{#if facts.count > 0}
|
||||
{@render amounts(facts.sums, true)}
|
||||
<span>{when}</span>
|
||||
<span>· {counted(facts.count, noun)}</span>
|
||||
{#if facts.median}
|
||||
<span
|
||||
>· median
|
||||
<span class="num text-cladd-fg"
|
||||
>{fmt(facts.median.code, facts.median.amount, 0)}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{#if perWeek !== null}
|
||||
<span
|
||||
>·
|
||||
<span class="num text-cladd-fg"
|
||||
>{formatNumber(perWeek, perWeek < 10 ? 1 : 0)}</span
|
||||
>
|
||||
per week</span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="font-semibold text-[22px] text-cladd-fg"
|
||||
>Nothing {when}</span
|
||||
>
|
||||
{#if model.history.last}
|
||||
<span>· last {formatDate(model.history.last)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !model.loading && main}
|
||||
<p
|
||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[13px] text-cladd-fg-soft"
|
||||
>
|
||||
{#if lead}
|
||||
{#if facts.count > 0}
|
||||
{@render amounts(facts.sums, false)}
|
||||
<span>{when} ·</span>
|
||||
{:else}
|
||||
<span>nothing {when} ·</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if typicals.length > 0}
|
||||
<span>typical month</span>
|
||||
{@render amounts(
|
||||
typicals.map((n) => ({ amount: n.typical, code: n.code })),
|
||||
false
|
||||
)}
|
||||
{#if main.projected !== null}
|
||||
<span
|
||||
>· on pace for ≈
|
||||
<span class="num font-semibold text-cladd-fg"
|
||||
>{fmt(main.code, main.projected, 0)}</span
|
||||
>,</span
|
||||
>
|
||||
{:else if main.ratio !== null}
|
||||
<span>·</span>
|
||||
{/if}
|
||||
{#if main.ratio !== null}
|
||||
<span class="font-semibold {tone(main.ratio)}"
|
||||
>{versusText(main.ratio)}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if model.typicalMonths.length > 0}
|
||||
<span
|
||||
>in {active} of the last {model.typicalMonths.length}
|
||||
{model.typicalMonths.length === 1 ? "month" : "months"}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import type { Facet } from "#lib/dimension/spec.js";
|
||||
import { type FacetRow, facetRows } from "#lib/dimension/stats.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import FacetRows from "./FacetRows.svelte";
|
||||
|
||||
let {
|
||||
model,
|
||||
facet,
|
||||
current,
|
||||
onfacet,
|
||||
onnarrow,
|
||||
class: className = "",
|
||||
}: {
|
||||
model: DimensionModel;
|
||||
facet: Facet;
|
||||
current: string | null;
|
||||
onfacet: (facet: Facet) => void;
|
||||
onnarrow: (facet: Facet, row: FacetRow) => void;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let focused = $state(false);
|
||||
let section = $state<HTMLElement>();
|
||||
|
||||
const labelOf = (f: Facet) =>
|
||||
model.dir === 1 && f.incomeLabel ? f.incomeLabel : f.label;
|
||||
const rows = $derived(
|
||||
facetRows(
|
||||
facet,
|
||||
model.periodMain,
|
||||
model.main,
|
||||
model.months,
|
||||
model.codes,
|
||||
model.weight
|
||||
)
|
||||
);
|
||||
const marker = $derived(model.months.indexOf(model.focus ?? ""));
|
||||
|
||||
useKeys(() =>
|
||||
model.spec.facets.map((f, i) => ({
|
||||
group: "Inside",
|
||||
keys: String(i + 1),
|
||||
label: `Inside: ${labelOf(f)}`,
|
||||
run: () => {
|
||||
onfacet(f);
|
||||
focused = true;
|
||||
},
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onpointerdown={(event) => {
|
||||
if (focused && !section?.contains(event.target as Node)) {
|
||||
focused = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Inside"
|
||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {focused
|
||||
? "outline-2 outline-brand"
|
||||
: ""} {className}"
|
||||
bind:this={section}
|
||||
>
|
||||
<header
|
||||
class="flex min-h-10 shrink-0 items-center gap-1 overflow-x-auto border-cladd-outline border-b px-2"
|
||||
role="tablist"
|
||||
>
|
||||
{#each model.spec.facets as f, i (f.id)}
|
||||
<button
|
||||
aria-selected={f.id === facet.id}
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 rounded-cladd-sm px-2 text-[13px] {f.id ===
|
||||
facet.id
|
||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => onfacet(f)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span class="kbd">{i + 1}</span>
|
||||
{labelOf(f)}
|
||||
</button>
|
||||
{/each}
|
||||
</header>
|
||||
{#key facet.id}
|
||||
<FacetRows
|
||||
active={focused}
|
||||
{current}
|
||||
label={labelOf(facet)}
|
||||
loading={model.loading}
|
||||
{marker}
|
||||
onleave={() => (focused = false)}
|
||||
onnarrow={(row) => {
|
||||
focused = false;
|
||||
onnarrow(facet, row);
|
||||
}}
|
||||
{rows}
|
||||
/>
|
||||
{/key}
|
||||
</section>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import type { Group } from "#lib/transactions/split.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let {
|
||||
groups,
|
||||
narrowed,
|
||||
onclear,
|
||||
loading = false,
|
||||
accountId,
|
||||
empty,
|
||||
}: {
|
||||
groups: Group[];
|
||||
narrowed: { groups: Group[]; label: string } | null;
|
||||
onclear: () => void;
|
||||
loading?: boolean;
|
||||
accountId?: string;
|
||||
empty: string;
|
||||
} = $props();
|
||||
|
||||
const shown = $derived(narrowed?.groups ?? groups);
|
||||
const size = $derived(session.pageSize);
|
||||
const pages = $derived(Math.max(1, Math.ceil(shown.length / size)));
|
||||
const current = $derived(
|
||||
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
|
||||
);
|
||||
const slice = $derived(shown.slice((current - 1) * size, current * size));
|
||||
|
||||
function href(n: number) {
|
||||
const url = new URL(page.url.href);
|
||||
url.searchParams.set("page", String(n));
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Box body="p-0" title="Transactions">
|
||||
{#snippet tools()}
|
||||
{#if narrowed}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
><span class="font-semibold text-cladd-fg">{narrowed.label}</span>
|
||||
<span class="num"
|
||||
>{narrowed.groups.length}
|
||||
of {groups.length}</span
|
||||
></span
|
||||
>
|
||||
<button
|
||||
aria-label="Show all {groups.length}"
|
||||
class="kbd hover:text-cladd-fg"
|
||||
onclick={onclear}
|
||||
type="button"
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
{:else}
|
||||
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
|
||||
{/if}
|
||||
<Pager {href} keys page={current} {pages} />
|
||||
{/snippet}
|
||||
<TransactionTable
|
||||
context={{ accountId }}
|
||||
{empty}
|
||||
groups={slice}
|
||||
id="dimension-tx"
|
||||
{loading}
|
||||
/>
|
||||
</Box>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { fmt, type LimitFact } from "#lib/dimension/stats.js";
|
||||
import { percent } from "#lib/format.js";
|
||||
|
||||
let { facts }: { facts: LimitFact[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="-mt-1 flex flex-col gap-0.5 pb-3 text-[13px] text-cladd-fg-soft">
|
||||
{#each facts as fact (fact.id)}
|
||||
{@const expected =
|
||||
fact.day === null ? null : (fact.amount * fact.day) / fact.days}
|
||||
{@const left = fact.amount - fact.spent}
|
||||
<p class="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span
|
||||
>limit
|
||||
<span class="num font-semibold text-cladd-fg"
|
||||
>{fmt(fact.code, fact.amount, 0)}</span
|
||||
></span
|
||||
>
|
||||
<span
|
||||
>· spent
|
||||
<span class="num text-cladd-fg"
|
||||
>{percent(fact.amount > 0 ? fact.spent / fact.amount : 0)}</span
|
||||
></span
|
||||
>
|
||||
{#if fact.day !== null && expected !== null}
|
||||
<span>· day {fact.day} of {fact.days} →</span>
|
||||
{#if fact.spent > expected}
|
||||
<span class="font-semibold text-expense"
|
||||
>{fmt(fact.code, fact.spent - expected, 0)}
|
||||
over pace</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-semibold text-income">under pace</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if left >= 0}
|
||||
<span
|
||||
>·
|
||||
<span class="num text-cladd-fg">{fmt(fact.code, left, 0)}</span>
|
||||
left</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
>·
|
||||
<span class="num font-semibold text-expense"
|
||||
>{fmt(fact.code, -left, 0)}</span
|
||||
>
|
||||
overspent</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<p>no limit for this period</p>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { fmt, type StripCell } from "#lib/dimension/stats.js";
|
||||
import { longMonth, shortMonth } from "#lib/reports/board.js";
|
||||
|
||||
let {
|
||||
strips,
|
||||
budgetId,
|
||||
cursor,
|
||||
}: {
|
||||
strips: Map<string, StripCell[]>;
|
||||
budgetId: string;
|
||||
cursor: string | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet meter(
|
||||
cell: StripCell,
|
||||
code: string
|
||||
)}
|
||||
{@const over = cell.spent > cell.amount}
|
||||
<span
|
||||
class="text-[11px] max-sm:hidden {cell.month === cursor
|
||||
? "font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft"}"
|
||||
>{shortMonth(cell.month)}</span
|
||||
>
|
||||
<span class="relative h-1 w-full rounded-full bg-cladd-surface-next">
|
||||
{#if cell.id}
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 rounded-full {over
|
||||
? "bg-expense"
|
||||
: "bg-brand"}"
|
||||
style:width="{Math.min(1, cell.spent / (cell.amount || 1)) * 100}%"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="sr-only"
|
||||
>{cell.id
|
||||
? `${fmt(code, cell.spent, 0)} of ${fmt(code, cell.amount, 0)}`
|
||||
: "no limit"}</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#each [...strips] as [code, cells] (code)}
|
||||
<nav
|
||||
aria-label="Limits in {code}"
|
||||
class="mt-2 grid gap-1 border-cladd-outline border-t pt-2"
|
||||
style:grid-template-columns="2.5rem repeat({cells.length}, minmax(0, 1fr))"
|
||||
>
|
||||
<span class="self-center font-semibold text-[12px] text-cladd-fg"
|
||||
>{code}</span
|
||||
>
|
||||
{#each cells as cell (cell.month)}
|
||||
{#if cell.id}
|
||||
<a
|
||||
class="flex flex-col items-center gap-1 rounded-cladd-sm px-1 py-0.5 hover:bg-row-hover {cell.month ===
|
||||
cursor
|
||||
? "bg-row-cursor"
|
||||
: ""}"
|
||||
href="/budgets/show/{budgetId}/{cell.id}"
|
||||
title="{longMonth(cell.month)}: {fmt(code, cell.spent, 0)} of {fmt(
|
||||
code,
|
||||
cell.amount,
|
||||
0
|
||||
)}"
|
||||
>
|
||||
{@render meter(cell, code)}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex flex-col items-center gap-1 px-1 py-0.5">
|
||||
{@render meter(cell, code)}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
{/each}
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { daysBetween } from "#lib/dates.js";
|
||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import { merchantSpec } from "#lib/dimension/spec.js";
|
||||
import { counted, factsOf, fmt } from "#lib/dimension/stats.js";
|
||||
import { formatDate, formatNumber } from "#lib/format.js";
|
||||
import Dimension from "./Dimension.svelte";
|
||||
|
||||
let { id, everything = false }: { id: string; everything?: boolean } =
|
||||
$props();
|
||||
|
||||
const model = new DimensionModel(() => ({
|
||||
allTime: true,
|
||||
everything,
|
||||
spec: merchantSpec(id),
|
||||
}));
|
||||
|
||||
const history = $derived(
|
||||
factsOf(
|
||||
model.main.filter((flow) => flow.date <= model.now),
|
||||
model.codes
|
||||
)
|
||||
);
|
||||
const noun = $derived(model.dir === -1 ? "visits" : "payments");
|
||||
const every = $derived(
|
||||
history.count > 1
|
||||
? daysBetween(history.first, history.last) / (history.count - 1)
|
||||
: null
|
||||
);
|
||||
const bill = $derived(model.main.find((flow) => flow.bill)?.bill ?? null);
|
||||
</script>
|
||||
|
||||
{#snippet lead()}
|
||||
{#if !model.loading}
|
||||
<p
|
||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
|
||||
>
|
||||
{#if history.count > 0}
|
||||
{#each history.sums as sum, i (sum.code)}
|
||||
{#if i > 0}
|
||||
<span>+</span>
|
||||
{/if}
|
||||
<span class="num font-semibold text-[22px] text-cladd-fg"
|
||||
>{fmt(sum.code, sum.amount, 0)}</span
|
||||
>
|
||||
{/each}
|
||||
<span>all time · {counted(history.count, noun)}</span>
|
||||
{#if history.median}
|
||||
<span
|
||||
>· median
|
||||
<span class="num text-cladd-fg"
|
||||
>{fmt(history.median.code, history.median.amount, 0)}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{#if every !== null}
|
||||
<span
|
||||
>· every
|
||||
<span class="num text-cladd-fg"
|
||||
>{formatNumber(every, every < 10 ? 1 : 0)}</span
|
||||
>
|
||||
days</span
|
||||
>
|
||||
{/if}
|
||||
<span
|
||||
>· last
|
||||
<span class="text-cladd-fg"
|
||||
>{formatDate(history.last, "short")}</span
|
||||
></span
|
||||
>
|
||||
{#if bill}
|
||||
<span
|
||||
>· subscription
|
||||
<a
|
||||
class="text-brand hover:underline"
|
||||
href="/subscriptions/show/{bill.id}"
|
||||
>{bill.name}</a
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="font-semibold text-[22px] text-cladd-fg"
|
||||
>No {noun} yet</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Dimension accountId={id} {lead} {model} {noun} />
|
||||
@@ -1,39 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||
import type { One, Tag, TransactionGroup } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { invalidate, lists } from "#lib/api/lists.js";
|
||||
import {
|
||||
periodPath,
|
||||
usePeriodPath,
|
||||
} from "#lib/components/budgets/period-path.svelte.js";
|
||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
|
||||
import {
|
||||
currencies,
|
||||
FLOW_KEYS,
|
||||
flowRows,
|
||||
} from "#lib/components/categories/flow.js";
|
||||
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||
import PeriodList from "#lib/components/categories/PeriodList.svelte";
|
||||
import {
|
||||
firstDay,
|
||||
inRange,
|
||||
list,
|
||||
periodRanges,
|
||||
rows as splitRows,
|
||||
stepLabel,
|
||||
totals,
|
||||
} from "#lib/components/categories/sums.js";
|
||||
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||
import Dimension from "#lib/components/dimension/Dimension.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { formatDate, formatMoney } from "#lib/format.js";
|
||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
||||
import { tagSpec } from "#lib/dimension/spec.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const ID = /^\d+$/;
|
||||
|
||||
@@ -41,94 +22,57 @@
|
||||
$props();
|
||||
|
||||
const tags = createQuery(() => lists.tags());
|
||||
const id = $derived(
|
||||
ID.test(param)
|
||||
? param
|
||||
: tags.data?.find((t) => t.attributes.tag === param)?.id
|
||||
const tag = $derived(
|
||||
tags.data?.find((t) =>
|
||||
ID.test(param) ? t.id === param : t.attributes.tag === param
|
||||
)
|
||||
);
|
||||
const base = $derived(`/tags/show/${id ?? encodeURIComponent(param)}`);
|
||||
const name = $derived(tag?.attributes.tag ?? (ID.test(param) ? "" : param));
|
||||
const base = $derived(`/tags/show/${tag?.id ?? encodeURIComponent(param)}`);
|
||||
usePeriodPath(() => base);
|
||||
|
||||
const tag = createQuery(() => ({
|
||||
enabled: Boolean(id),
|
||||
queryFn: () => api<One<Tag>>(`/v1/tags/${id}`),
|
||||
queryKey: ["tag", id],
|
||||
const model = new DimensionModel(() => ({
|
||||
everything,
|
||||
spec: tagSpec(name, tags.data ?? []),
|
||||
}));
|
||||
const groups = createQuery(() => ({
|
||||
enabled: Boolean(id),
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/tags/${id}/transactions`, { type: "all" }),
|
||||
queryKey: ["tag-transactions", id],
|
||||
}));
|
||||
|
||||
const attributes = $derived(tag.data?.data.attributes);
|
||||
const name = $derived(attributes?.tag ?? param);
|
||||
const keep = $derived((s: { tags?: string[] | null }) =>
|
||||
(s.tags ?? []).includes(name)
|
||||
const other = $derived(everything ? base : `${base}/all`);
|
||||
const ancestors = $derived(
|
||||
name
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.map((_, i, parts) => parts.slice(0, i + 1).join("/"))
|
||||
);
|
||||
const own = $derived(splitRows(groups.data ?? [], keep));
|
||||
const scoped = $derived(everything ? own : inRange(own, period.range));
|
||||
const sums = $derived(totals(scoped));
|
||||
const net = $derived.by(() => {
|
||||
const result = { ...sums.earned };
|
||||
for (const [code, sum] of Object.entries(sums.spent)) {
|
||||
result[code] = {
|
||||
...sum,
|
||||
amount: (result[code]?.amount ?? 0) + sum.amount,
|
||||
};
|
||||
}
|
||||
return list(result);
|
||||
});
|
||||
const children = $derived(
|
||||
(tags.data ?? []).filter((t) => t.attributes.tag.startsWith(`${name}/`))
|
||||
const subtitle = $derived(
|
||||
[everything ? "All time" : period.label, tag?.attributes.description]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
);
|
||||
const parentPath = $derived(
|
||||
name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : ""
|
||||
);
|
||||
const parent = $derived(
|
||||
tags.data?.find((t) => t.attributes.tag === parentPath)
|
||||
);
|
||||
|
||||
const curs = $derived(currencies(own));
|
||||
const cur = $derived(
|
||||
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
|
||||
curs[0]
|
||||
);
|
||||
const fmt = $derived((value: number) =>
|
||||
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
|
||||
);
|
||||
const history = $derived.by(() => {
|
||||
if (!cur) {
|
||||
return [];
|
||||
}
|
||||
const first = firstDay(own) ?? period.start;
|
||||
const ranges = periodRanges(period.end, period.view, 400)
|
||||
.filter((r) => r.end >= first)
|
||||
.reverse();
|
||||
return flowRows(
|
||||
own,
|
||||
ranges,
|
||||
cur,
|
||||
(r) => stepLabel(r, period.view),
|
||||
(r) => periodPath(base, r.start, r.end)
|
||||
);
|
||||
});
|
||||
|
||||
async function remove() {
|
||||
if (!id) {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
await api(`/v1/tags/${id}`, { method: "DELETE" });
|
||||
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
|
||||
await invalidate();
|
||||
await goto("/tags");
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
...(tag
|
||||
? [
|
||||
{
|
||||
group: "Tag",
|
||||
keys: "E",
|
||||
label: "Edit tag",
|
||||
run: () => id && goto(`/tags/edit/${id}`),
|
||||
run: () => goto(`/tags/edit/${tag.id}`),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
group: "Tag",
|
||||
keys: "A",
|
||||
label: everything ? "Show the current period" : "Show all time",
|
||||
run: () => goto(other),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
@@ -136,145 +80,41 @@
|
||||
<PageHeader
|
||||
crumbs={[
|
||||
{ href: "/tags", label: "Tags" },
|
||||
...(parent
|
||||
? [{ href: `/tags/show/${parent.id}`, label: parent.attributes.tag }]
|
||||
: []),
|
||||
{ label: name },
|
||||
...ancestors.map((path) => ({
|
||||
href: `/tags/show/${encodeURIComponent(path)}`,
|
||||
label: path.slice(path.lastIndexOf("/") + 1),
|
||||
})),
|
||||
{ label: name.slice(name.lastIndexOf("/") + 1) || "Tag" },
|
||||
]}
|
||||
subtitle={everything
|
||||
? "All transactions"
|
||||
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
|
||||
title={name}
|
||||
{subtitle}
|
||||
title={name || "Tag"}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href={other} size="sm" title="Shift+A" variant="outline"
|
||||
>{everything ? "This period" : "All time"}</Button
|
||||
>
|
||||
{#if tag}
|
||||
<Button
|
||||
href="/tags/edit/{id}"
|
||||
href="/tags/edit/{tag.id}"
|
||||
size="sm"
|
||||
title="Edit tag (E)"
|
||||
title="Shift+E"
|
||||
variant="outline"
|
||||
>Edit</Button
|
||||
>
|
||||
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if tag.isError}
|
||||
<p class="pb-3 text-expense">{tag.error.message}</p>
|
||||
{#if tags.isError}
|
||||
<p class="pb-3 text-expense">{tags.error.message}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid items-start gap-4 lg:grid-cols-2">
|
||||
<Box body="p-0" title="Meta data">
|
||||
<table class="w-full text-[13px]">
|
||||
<tbody>
|
||||
{#if attributes?.description}
|
||||
<tr class="border-cladd-outline border-b">
|
||||
<th
|
||||
class="w-1/2 px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
|
||||
>
|
||||
Description
|
||||
</th>
|
||||
<td class="px-3 py-1.5">{attributes.description}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if attributes?.date}
|
||||
<tr class="border-cladd-outline border-b">
|
||||
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
|
||||
Date
|
||||
</th>
|
||||
<td class="num px-3 py-1.5">{formatDate(attributes.date)}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each [
|
||||
{ label: "Sum (excluding transfers)", sums: net },
|
||||
{ label: "Sum of withdrawals", sums: list(sums.spent) },
|
||||
{ label: "Sum of deposits", sums: list(sums.earned) },
|
||||
{ label: "Sum of transfers", sums: list(sums.transferred) },
|
||||
] as row (row.label)}
|
||||
{#if row.sums.length > 0}
|
||||
<tr class="border-cladd-outline border-b">
|
||||
<th
|
||||
class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
|
||||
>
|
||||
{row.label}
|
||||
</th>
|
||||
<td class="px-3 py-1.5">
|
||||
<MoneyList
|
||||
class="!justify-start"
|
||||
sums={row.sums}
|
||||
tone={row.label === "Sum of transfers"
|
||||
? "transfer"
|
||||
: "auto"}
|
||||
<Dimension
|
||||
{model}
|
||||
noun={model.dir === -1 ? "purchases" : "payments"}
|
||||
onmonth={(range) =>
|
||||
everything
|
||||
? goto(periodPath(base, range.start, range.end))
|
||||
: period.set(range)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if children.length > 0}
|
||||
<tr class="border-cladd-outline border-b">
|
||||
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
|
||||
Sub-tags
|
||||
</th>
|
||||
<td class="px-3 py-1.5">
|
||||
{#each children as child, i (child.id)}
|
||||
<a
|
||||
class="text-brand hover:underline"
|
||||
href="/tags/show/{child.id}"
|
||||
>{child.attributes.tag.slice(name.length + 1)}</a
|
||||
>{i < children.length - 1 ? ", " : ""}
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if scoped.length === 0 && !groups.isPending}
|
||||
<tr>
|
||||
<td class="px-3 py-1.5 text-cladd-fg-soft" colspan="2">
|
||||
No transactions in this range.
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
<Box title="Chart for all transactions for tag "{name}"">
|
||||
{#snippet tools()}
|
||||
<CurrencyTabs
|
||||
codes={curs.map((c) => c.code)}
|
||||
current={cur?.code ?? ""}
|
||||
/>
|
||||
{/snippet}
|
||||
{#if history.length > 0}
|
||||
<StackedBars
|
||||
format={fmt}
|
||||
height={200}
|
||||
keys={FLOW_KEYS}
|
||||
label="Spent and earned per period"
|
||||
rows={history}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-[12px] text-cladd-fg-soft">No transactions yet.</p>
|
||||
{/if}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<PeriodList
|
||||
groups={groups.data ?? []}
|
||||
href={(r) => periodPath(base, r.start, r.end)}
|
||||
{keep}
|
||||
loading={groups.isPending}
|
||||
sidebar={!everything}
|
||||
title="Transactions"
|
||||
>
|
||||
{#snippet links()}
|
||||
{#if everything}
|
||||
<a class="text-brand hover:underline" href={base}
|
||||
>Show the current period and overview</a
|
||||
>
|
||||
{:else}
|
||||
<a class="text-brand hover:underline" href="{base}/all"
|
||||
>Show all transactions without grouping them by date</a
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PeriodList>
|
||||
</div>
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type CreateQueryResult, createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import { contains, type Range, today } from "#lib/dates.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { monthOf } from "#lib/reports/board.js";
|
||||
import { buckets, type Flow } from "#lib/reports/flows.js";
|
||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||
import { pages } from "#lib/reports/queries.js";
|
||||
import { impliedRates } from "#lib/reports/rates.js";
|
||||
import { typicalMonths } from "#lib/reports/typical.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import type { Group } from "#lib/transactions/split.js";
|
||||
import type { Spec } from "./spec.js";
|
||||
import { codesOf, dirOf, factsOf, normsOf, weigh } from "./stats.js";
|
||||
|
||||
export interface Args {
|
||||
allTime?: boolean;
|
||||
everything?: boolean;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
interface Kept {
|
||||
groups: Group[];
|
||||
journals: Set<string>;
|
||||
}
|
||||
|
||||
function keptOf(
|
||||
groups: Group[],
|
||||
keep: (split: Split) => boolean,
|
||||
range: Range | null
|
||||
): Kept {
|
||||
const journals = new Set<string>();
|
||||
const kept: Group[] = [];
|
||||
for (const group of groups) {
|
||||
let hit = false;
|
||||
for (const split of group.attributes.transactions) {
|
||||
if (keep(split) && (!range || contains(range, split.date.slice(0, 10)))) {
|
||||
journals.add(String(split.transaction_journal_id));
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
kept.push(group);
|
||||
}
|
||||
}
|
||||
return { groups: kept, journals };
|
||||
}
|
||||
|
||||
const flowsOf = (kept: Kept, accounts: Item<Account>[]): Flow[] =>
|
||||
ownFlows(kept.groups, accounts).filter((flow) =>
|
||||
kept.journals.has(flow.journal)
|
||||
);
|
||||
|
||||
export class DimensionModel {
|
||||
readonly now = today();
|
||||
readonly args: () => Args;
|
||||
readonly ledger: CreateQueryResult<Group[]>;
|
||||
readonly accounts: CreateQueryResult<Item<Account>[]>;
|
||||
readonly source: CreateQueryResult<Group[]>;
|
||||
|
||||
spec = $derived.by(() => this.args().spec);
|
||||
everything = $derived.by(() => Boolean(this.args().everything));
|
||||
allTime = $derived.by(() => this.everything || Boolean(this.args().allTime));
|
||||
range = $derived<Range | null>(this.everything ? null : period.range);
|
||||
anchor = $derived(
|
||||
this.range && this.range.end < ledgerWindow(this.now).start
|
||||
? this.range.end
|
||||
: this.now
|
||||
);
|
||||
window = $derived(ledgerWindow(this.anchor));
|
||||
inside = $derived(
|
||||
this.range !== null &&
|
||||
this.range.start >= this.window.start &&
|
||||
this.range.end <= this.window.end
|
||||
);
|
||||
sourceRange = $derived(this.allTime ? null : this.range);
|
||||
|
||||
raw = $derived.by(
|
||||
() =>
|
||||
(this.allTime ? this.source.data : this.ledger.data) ?? ([] as Group[])
|
||||
);
|
||||
loading = $derived.by(
|
||||
() =>
|
||||
this.accounts.isPending ||
|
||||
(this.allTime ? this.source.isPending : this.ledger.isPending) ||
|
||||
(!(this.allTime || this.inside) && this.source.isPending)
|
||||
);
|
||||
base = $derived(keptOf(this.raw, this.spec.keep, null));
|
||||
inPeriod = $derived.by(() => {
|
||||
if (this.everything) {
|
||||
return this.base;
|
||||
}
|
||||
const from =
|
||||
this.allTime || this.inside ? this.raw : (this.source.data ?? []);
|
||||
return keptOf(from, this.spec.keep, this.range);
|
||||
});
|
||||
groups = $derived(this.inPeriod.groups);
|
||||
flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
|
||||
periodFlows = $derived.by(() =>
|
||||
flowsOf(this.inPeriod, this.accounts.data ?? [])
|
||||
);
|
||||
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
|
||||
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
|
||||
periodMain = $derived(
|
||||
this.periodFlows.filter((flow) => flow.dir === this.dir)
|
||||
);
|
||||
codes = $derived(codesOf([...this.main, ...this.periodMain]));
|
||||
history = $derived(factsOf(this.main, this.codes));
|
||||
months = $derived(
|
||||
this.everything && this.history.first
|
||||
? buckets(this.history.first, this.now, "month")
|
||||
: this.window.months
|
||||
);
|
||||
typicalMonths = $derived(
|
||||
typicalMonths(this.months, this.now, period.first ?? this.history.first)
|
||||
);
|
||||
focus = $derived(
|
||||
this.range && monthOf(this.range.start) === monthOf(this.range.end)
|
||||
? monthOf(this.range.start)
|
||||
: null
|
||||
);
|
||||
partial = $derived(
|
||||
this.months.includes(monthOf(this.now)) ? monthOf(this.now) : null
|
||||
);
|
||||
norms = $derived(
|
||||
normsOf(this.main, this.codes, this.typicalMonths, this.focus, this.now)
|
||||
);
|
||||
facts = $derived(factsOf(this.periodMain, this.codes));
|
||||
weight = $derived(
|
||||
weigh(
|
||||
[...this.main, ...this.periodMain],
|
||||
session.primary?.code ?? "",
|
||||
impliedRates(this.raw)
|
||||
)
|
||||
);
|
||||
|
||||
constructor(args: () => Args) {
|
||||
this.args = args;
|
||||
this.ledger = createQuery(() => ({
|
||||
...ledgerQuery(this.anchor),
|
||||
enabled: !this.allTime,
|
||||
}));
|
||||
this.accounts = createQuery(() => lists.accounts("all"));
|
||||
this.source = createQuery(() => {
|
||||
const range = this.sourceRange;
|
||||
return {
|
||||
enabled: this.allTime || !this.inside,
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>(this.spec.source, {
|
||||
...(range ?? {}),
|
||||
type: "default",
|
||||
}),
|
||||
queryKey: [
|
||||
"dimension",
|
||||
this.spec.source,
|
||||
range?.start ?? "all",
|
||||
range?.end ?? "all",
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Item, Split, Tag } from "#lib/api/types.js";
|
||||
import { buildTree, flatten } from "#lib/components/tags/tree.js";
|
||||
import { type Flow, type Key, keys, NONE } from "#lib/reports/flows.js";
|
||||
|
||||
export interface Facet {
|
||||
id: string;
|
||||
incomeLabel?: string;
|
||||
label: string;
|
||||
of: (flow: Flow) => Key[];
|
||||
}
|
||||
|
||||
export interface Spec {
|
||||
facets: Facet[];
|
||||
keep: (split: Split) => boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const listOf = (pick: (flow: Flow) => Key | null) => (flow: Flow) => {
|
||||
const key = pick(flow);
|
||||
return key ? [key] : [];
|
||||
};
|
||||
|
||||
const facets = {
|
||||
budgets: { id: "budgets", label: "Budgets", of: listOf(keys.budget) },
|
||||
cards: { id: "cards", label: "Cards", of: listOf(keys.asset) },
|
||||
categories: {
|
||||
id: "categories",
|
||||
label: "Categories",
|
||||
of: listOf(keys.category),
|
||||
},
|
||||
merchants: {
|
||||
id: "merchants",
|
||||
incomeLabel: "Payers",
|
||||
label: "Merchants",
|
||||
of: listOf(keys.other),
|
||||
},
|
||||
tags: { id: "tags", label: "Tags", of: keys.tag },
|
||||
} satisfies Record<string, Facet>;
|
||||
|
||||
export const categorySpec = (id: string): Spec => ({
|
||||
facets: [facets.merchants, facets.cards, facets.budgets, facets.tags],
|
||||
keep: (split) => String(split.category_id ?? "") === id,
|
||||
source: `/v1/categories/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const budgetSpec = (id: string): Spec => ({
|
||||
facets: [facets.merchants, facets.cards, facets.categories, facets.tags],
|
||||
keep: (split) => String(split.budget_id ?? "") === id,
|
||||
source: `/v1/budgets/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const merchantSpec = (id: string): Spec => ({
|
||||
facets: [facets.categories, facets.cards, facets.budgets, facets.tags],
|
||||
keep: (split) =>
|
||||
String(split.source_id) === id || String(split.destination_id) === id,
|
||||
source: `/v1/accounts/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const underTag = (name: string, tag: string) =>
|
||||
tag === name || tag.startsWith(`${name}/`);
|
||||
|
||||
function subtagFacet(name: string, tags: Item<Tag>[]): Facet | null {
|
||||
const node = flatten(buildTree(tags)).find((n) => n.path === name);
|
||||
const children = node?.children ?? [];
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const childOf = (tag: string): Key | null => {
|
||||
if (tag === name) {
|
||||
return { key: NONE, label: "(no subtag)" };
|
||||
}
|
||||
const child = children.find((c) => underTag(c.path, tag));
|
||||
return child
|
||||
? {
|
||||
href: `/tags/show/${encodeURIComponent(child.path)}`,
|
||||
key: child.path,
|
||||
label: child.name,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
return {
|
||||
id: "subtags",
|
||||
label: "Subtags",
|
||||
of: (flow) => {
|
||||
const found = new Map<string, Key>();
|
||||
for (const tag of flow.tags) {
|
||||
const key = childOf(tag);
|
||||
if (key) {
|
||||
found.set(key.key, key);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function tagSpec(name: string, tags: Item<Tag>[]): Spec {
|
||||
const subtags = subtagFacet(name, tags);
|
||||
return {
|
||||
facets: [
|
||||
...(subtags ? [subtags] : []),
|
||||
facets.merchants,
|
||||
facets.cards,
|
||||
facets.categories,
|
||||
],
|
||||
keep: (split) => (split.tags ?? []).some((tag) => underTag(name, tag)),
|
||||
source: "/v1/transactions",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { BudgetLimit, Item } from "#lib/api/types.js";
|
||||
import {
|
||||
type Lane,
|
||||
type StackKey,
|
||||
tones,
|
||||
wash,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import { contains, daysBetween, type Range } from "#lib/dates.js";
|
||||
import { monthOf, shortMonth } from "#lib/reports/board.js";
|
||||
import { type Flow, group, type Totals } from "#lib/reports/flows.js";
|
||||
import type { Rate } from "#lib/reports/rates.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
import { median, pace, series, typical, versus } from "#lib/reports/typical.js";
|
||||
import { modeAxis } from "#lib/reports/view.js";
|
||||
import type { Facet } from "./spec.js";
|
||||
|
||||
export type Dir = 1 | -1;
|
||||
|
||||
export interface Money {
|
||||
amount: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface Facts {
|
||||
count: number;
|
||||
first: string;
|
||||
last: string;
|
||||
median: Money | null;
|
||||
sums: Money[];
|
||||
}
|
||||
|
||||
export interface Norm {
|
||||
code: string;
|
||||
projected: number | null;
|
||||
ratio: number | null;
|
||||
typical: number;
|
||||
}
|
||||
|
||||
export interface FacetRow {
|
||||
href?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
share: number;
|
||||
spark: number[];
|
||||
sums: Money[];
|
||||
}
|
||||
|
||||
export interface LimitFact {
|
||||
amount: number;
|
||||
code: string;
|
||||
day: number | null;
|
||||
days: number;
|
||||
id: string;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface StripCell {
|
||||
amount: number;
|
||||
id: string | null;
|
||||
month: string;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
const BRAND = "var(--ff-brand)";
|
||||
const PLURAL = /s$/;
|
||||
const LEFT_WASH = 28;
|
||||
|
||||
export const counted = (count: number, noun: string) =>
|
||||
`${count} ${count === 1 ? noun.replace(PLURAL, "") : noun}`;
|
||||
|
||||
export const fmt = (code: string, amount: number, decimals?: number) =>
|
||||
money(code, amount, false, decimals);
|
||||
|
||||
export function dirOf(flows: Flow[]): Dir {
|
||||
const out = flows.filter((flow) => flow.dir === -1).length;
|
||||
return out * 2 >= flows.length ? -1 : 1;
|
||||
}
|
||||
|
||||
function tally(flows: Flow[]) {
|
||||
const by = new Map<string, { count: number; sum: number }>();
|
||||
for (const flow of flows) {
|
||||
const t = by.get(flow.currency) ?? { count: 0, sum: 0 };
|
||||
t.count += 1;
|
||||
t.sum += flow.amount;
|
||||
by.set(flow.currency, t);
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
export function codesOf(flows: Flow[]): string[] {
|
||||
const by = tally(flows);
|
||||
return [...by.keys()].sort(
|
||||
(a, b) => (by.get(b)?.count ?? 0) - (by.get(a)?.count ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function sumsOf(flows: Flow[], codes: string[]): Money[] {
|
||||
const by = tally(flows);
|
||||
return codes
|
||||
.filter((code) => by.has(code))
|
||||
.map((code) => ({ amount: by.get(code)?.sum ?? 0, code }));
|
||||
}
|
||||
|
||||
export function factsOf(flows: Flow[], codes: string[]): Facts {
|
||||
const main = codes.find((code) => flows.some((f) => f.currency === code));
|
||||
let first = "";
|
||||
let last = "";
|
||||
for (const flow of flows) {
|
||||
first = !first || flow.date < first ? flow.date : first;
|
||||
last = flow.date > last ? flow.date : last;
|
||||
}
|
||||
const amounts = flows.filter((f) => f.currency === main).map((f) => f.amount);
|
||||
return {
|
||||
count: new Set(flows.map((flow) => flow.group)).size,
|
||||
first,
|
||||
last,
|
||||
median: main ? { amount: median(amounts), code: main } : null,
|
||||
sums: sumsOf(flows, codes),
|
||||
};
|
||||
}
|
||||
|
||||
export function normsOf(
|
||||
flows: Flow[],
|
||||
codes: string[],
|
||||
months: string[],
|
||||
focus: string | null,
|
||||
now: string
|
||||
): Norm[] {
|
||||
const by = series(flows, (flow) => flow.currency);
|
||||
return codes.map((code) => {
|
||||
const typ = typical(by.get(code), months);
|
||||
if (focus === monthOf(now)) {
|
||||
const p = pace(
|
||||
flows.filter((flow) => flow.currency === code),
|
||||
months,
|
||||
now
|
||||
);
|
||||
const projected = p.sofar + typ - p.typical;
|
||||
return { code, projected, ratio: versus(projected, typ), typical: typ };
|
||||
}
|
||||
const value = focus ? (by.get(code)?.get(focus) ?? 0) : 0;
|
||||
return {
|
||||
code,
|
||||
projected: null,
|
||||
ratio: focus ? versus(value, typ) : null,
|
||||
typical: typ,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function lanesOf(
|
||||
flows: Flow[],
|
||||
months: string[],
|
||||
codes: string[],
|
||||
dir: Dir,
|
||||
norms: Norm[],
|
||||
height: number
|
||||
): Lane[] {
|
||||
const key: StackKey = {
|
||||
color: BRAND,
|
||||
key: "value",
|
||||
label: dir === -1 ? "Spent" : "Earned",
|
||||
};
|
||||
const main = series(
|
||||
flows.filter((flow) => flow.dir === dir),
|
||||
(flow) => flow.currency
|
||||
);
|
||||
const back = series(
|
||||
flows.filter((flow) => flow.dir !== dir),
|
||||
(flow) => flow.currency
|
||||
);
|
||||
return codes.map((code) => ({
|
||||
axisFormat: modeAxis(code),
|
||||
dots: back.has(code)
|
||||
? Object.fromEntries(
|
||||
months.map((month) => [month, back.get(code)?.get(month) ?? 0])
|
||||
)
|
||||
: undefined,
|
||||
format: (value: number) => fmt(code, value, 0),
|
||||
height,
|
||||
key: code,
|
||||
keys: [key],
|
||||
label: code,
|
||||
rows: months.map((month) => ({
|
||||
key: month,
|
||||
label: shortMonth(month),
|
||||
values: { value: main.get(code)?.get(month) ?? 0 },
|
||||
})),
|
||||
typical: norms.find((n) => n.code === code)?.typical || null,
|
||||
}));
|
||||
}
|
||||
|
||||
const LIMIT_KEYS: StackKey[] = [
|
||||
{ color: BRAND, key: "spent", label: "Spent" },
|
||||
{ color: wash(BRAND, LEFT_WASH), key: "left", label: "Left" },
|
||||
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||
];
|
||||
|
||||
const limitKey = (code: string, month: string) => `${code} ${month}`;
|
||||
|
||||
function limitsByMonth(limits: Item<BudgetLimit>[]) {
|
||||
const by = new Map<string, number>();
|
||||
for (const limit of limits) {
|
||||
const k = limitKey(
|
||||
limit.attributes.currency_code ?? "",
|
||||
monthOf(limit.attributes.start ?? "")
|
||||
);
|
||||
by.set(k, (by.get(k) ?? 0) + Number(limit.attributes.amount));
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
export function withLimits(lanes: Lane[], limits: Item<BudgetLimit>[]): Lane[] {
|
||||
const by = limitsByMonth(limits);
|
||||
return lanes.map((lane) => ({
|
||||
...lane,
|
||||
keys: LIMIT_KEYS,
|
||||
rows: lane.rows.map((row) => {
|
||||
const spent = row.values.value ?? 0;
|
||||
const limit = by.get(limitKey(lane.key, row.key));
|
||||
const values: Record<string, number> = limit
|
||||
? {
|
||||
left: Math.max(0, limit - spent),
|
||||
over: Math.max(0, spent - limit),
|
||||
spent: Math.min(spent, limit),
|
||||
}
|
||||
: { spent };
|
||||
return { ...row, values };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
const limitRangeOf = (limit: Item<BudgetLimit>): Range => ({
|
||||
end: (limit.attributes.end ?? "").slice(0, 10),
|
||||
start: (limit.attributes.start ?? "").slice(0, 10),
|
||||
});
|
||||
|
||||
const spentIn = (flows: Flow[], code: string, range: Range) =>
|
||||
flows
|
||||
.filter((flow) => flow.currency === code && contains(range, flow.date))
|
||||
.reduce((sum, flow) => sum + flow.amount, 0);
|
||||
|
||||
export function limitFacts(
|
||||
limits: Item<BudgetLimit>[],
|
||||
flows: Flow[],
|
||||
range: Range,
|
||||
now: string
|
||||
): LimitFact[] {
|
||||
return limits
|
||||
.filter((limit) => {
|
||||
const r = limitRangeOf(limit);
|
||||
return r.start <= range.end && r.end >= range.start;
|
||||
})
|
||||
.map((limit) => {
|
||||
const r = limitRangeOf(limit);
|
||||
const code = limit.attributes.currency_code ?? "";
|
||||
return {
|
||||
amount: Number(limit.attributes.amount),
|
||||
code,
|
||||
day: contains(r, now) ? daysBetween(r.start, now) + 1 : null,
|
||||
days: daysBetween(r.start, r.end) + 1,
|
||||
id: limit.id,
|
||||
spent: spentIn(flows, code, r),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function stripsOf(
|
||||
limits: Item<BudgetLimit>[],
|
||||
flows: Flow[],
|
||||
months: string[]
|
||||
): Map<string, StripCell[]> {
|
||||
const spent = series(flows, (flow) => flow.currency);
|
||||
const strips = new Map<string, StripCell[]>();
|
||||
const codes = new Set(limits.map((l) => l.attributes.currency_code ?? ""));
|
||||
for (const code of codes) {
|
||||
const own = limits.filter((l) => l.attributes.currency_code === code);
|
||||
const cells = months.map((month) => {
|
||||
const limit = own.find(
|
||||
(l) => monthOf(l.attributes.start ?? "") === month
|
||||
);
|
||||
return {
|
||||
amount: limit ? Number(limit.attributes.amount) : 0,
|
||||
id: limit?.id ?? null,
|
||||
month,
|
||||
spent: spent.get(code)?.get(month) ?? 0,
|
||||
};
|
||||
});
|
||||
if (cells.some((cell) => cell.id)) {
|
||||
strips.set(code, cells);
|
||||
}
|
||||
}
|
||||
return strips;
|
||||
}
|
||||
|
||||
export function weigh(
|
||||
flows: Flow[],
|
||||
primary: string,
|
||||
rate: Rate
|
||||
): (code: string) => number {
|
||||
const codes = [...new Set(flows.map((flow) => flow.currency))];
|
||||
const rates = new Map(
|
||||
codes.map((code) => [code, code === primary ? 1 : rate(code, primary)])
|
||||
);
|
||||
if ([...rates.values()].every((r) => r !== null)) {
|
||||
return (code) => rates.get(code) ?? 0;
|
||||
}
|
||||
const by = tally(flows);
|
||||
return (code) => {
|
||||
const t = by.get(code);
|
||||
return t && t.sum > 0 ? t.count / t.sum : 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function facetRows(
|
||||
facet: Facet,
|
||||
period: Flow[],
|
||||
window: Flow[],
|
||||
months: string[],
|
||||
codes: string[],
|
||||
weight: (code: string) => number
|
||||
): FacetRow[] {
|
||||
const worth = (totals: Totals | undefined) => {
|
||||
let sum = 0;
|
||||
for (const [code, m] of totals?.cur ?? []) {
|
||||
sum += (m.in + m.out) * weight(code);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const sparks = new Map(
|
||||
group(window, facet.of, (flow) => monthOf(flow.date)).map((row) => [
|
||||
row.key,
|
||||
months.map((month) => worth(row.cells.get(month))),
|
||||
])
|
||||
);
|
||||
const total =
|
||||
period.reduce(
|
||||
(sum, flow) => sum + flow.amount * weight(flow.currency),
|
||||
0
|
||||
) || 1;
|
||||
return group(period, facet.of)
|
||||
.map((row) => ({
|
||||
href: row.href,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
share: worth(row.totals) / total,
|
||||
spark: sparks.get(row.key) ?? [],
|
||||
sums: codes
|
||||
.filter((code) => row.totals.cur.has(code))
|
||||
.map((code) => {
|
||||
const m = row.totals.cur.get(code);
|
||||
return { amount: (m?.in ?? 0) + (m?.out ?? 0), code };
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
|
||||
}
|
||||
Reference in new issue
Block a user