feat(reports): live firefly reports with drill-down, money flow and spending calendar
This commit is contained in:
1 parent
60caee10dc
commit
62cd17ecde
39 files changed
+6596
No files matched your search
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
const { data } = balances;
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return report.selected
|
||||
.map((id) => {
|
||||
const account = report.byId().get(id);
|
||||
const start = data.before.get(id) ?? 0;
|
||||
const end = data.after.get(id) ?? 0;
|
||||
return {
|
||||
code: account?.currency_code ?? "",
|
||||
end,
|
||||
id,
|
||||
name: account?.name ?? `#${id}`,
|
||||
start,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
const sums = $derived.by(() => {
|
||||
const map = new Map<string, { start: number; end: number }>();
|
||||
for (const row of rows) {
|
||||
const sum = map.get(row.code) ?? { end: 0, start: 0 };
|
||||
sum.start += row.start;
|
||||
sum.end += row.end;
|
||||
map.set(row.code, sum);
|
||||
}
|
||||
const order = report.codes;
|
||||
return [...map.entries()].sort(
|
||||
(a, b) => order.indexOf(a[0]) - order.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
|
||||
function tone(value: number) {
|
||||
if (value === 0) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return value < 0 ? "text-expense" : "text-income";
|
||||
}
|
||||
|
||||
function open(row: { id: string; name: string }) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "balances" && current.key === row.id;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: { box: "balances", dim: "asset", key: row.id, label: row.name }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
id="balances"
|
||||
loading={!(balances.data && report.accountsReady)}
|
||||
title="Account balances"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Name</th>
|
||||
<th class="h-7 px-3 text-right font-medium">
|
||||
Balance at start of period
|
||||
</th>
|
||||
<th class="h-7 px-3 text-right font-medium">
|
||||
Balance at end of period
|
||||
</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
{@const active =
|
||||
report.drill?.box === "balances" && report.drill.key === row.id}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[16rem] px-3">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => open(row)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
</td>
|
||||
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
|
||||
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
|
||||
<td class="num px-3 text-right {tone(row.end - row.start)}">
|
||||
{money(row.code, row.end - row.start, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{#each sums as [code, sum], i (code)}
|
||||
<tr
|
||||
class="h-7 font-semibold {i === 0
|
||||
? "border-cladd-outline border-t-2"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">Sum ({code})</td>
|
||||
<td class="num px-3 text-right">{money(code, sum.start)}</td>
|
||||
<td class="num px-3 text-right">{money(code, sum.end)}</td>
|
||||
<td class="num px-3 text-right {tone(sum.end - sum.start)}">
|
||||
{money(code, sum.end - sum.start, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
|
||||
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||
import Calculator from "@lucide/svelte/icons/calculator";
|
||||
import Star from "@lucide/svelte/icons/star";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import type { Account } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatDate, formatDateTime } from "#lib/format.js";
|
||||
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
id,
|
||||
account,
|
||||
before,
|
||||
after,
|
||||
columns,
|
||||
}: {
|
||||
id: string;
|
||||
account: Account | undefined;
|
||||
before: number | undefined;
|
||||
after: number | undefined;
|
||||
columns: AuditColumn[];
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const code = $derived(account?.currency_code ?? "");
|
||||
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
|
||||
const rows = $derived(
|
||||
query.data && before !== undefined
|
||||
? ledger(query.data, id, code, before)
|
||||
: undefined
|
||||
);
|
||||
const show = (key: AuditColumn) => columns.includes(key);
|
||||
const ICONS = {
|
||||
deposit: ArrowRight,
|
||||
"opening balance": Star,
|
||||
reconciliation: Calculator,
|
||||
transfer: ArrowLeftRight,
|
||||
withdrawal: ArrowLeft,
|
||||
} as const;
|
||||
const META_DATES = [
|
||||
"interest_date",
|
||||
"book_date",
|
||||
"process_date",
|
||||
"due_date",
|
||||
"payment_date",
|
||||
"invoice_date",
|
||||
] as const;
|
||||
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
|
||||
</script>
|
||||
|
||||
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
|
||||
{#if !rows}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="px-3 py-3">
|
||||
No activity was recorded on account
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/accounts/show/{id}"
|
||||
>{account?.name}</a
|
||||
>
|
||||
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="px-3 py-2 text-[13px]">
|
||||
Account balance of <strong>{account?.name}</strong> at the end of
|
||||
{formatDate(addDays(p.start, -1), "long")}
|
||||
was:
|
||||
<span class="num font-semibold">{money(code, before ?? 0)}</span>
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-[12px] text-cladd-fg-soft">
|
||||
{#if show("icon")}
|
||||
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
|
||||
{/if}
|
||||
{#if show("description")}
|
||||
<th class="h-7 px-3 font-medium">Description</th>
|
||||
{/if}
|
||||
{#if show("balance_before")}
|
||||
<th class="h-7 px-3 text-right font-medium">Balance before</th>
|
||||
{/if}
|
||||
{#if show("amount")}
|
||||
<th class="h-7 px-3 text-right font-medium">Amount</th>
|
||||
{/if}
|
||||
{#if show("balance_after")}
|
||||
<th class="h-7 px-3 text-right font-medium">Balance after</th>
|
||||
{/if}
|
||||
{#if show("date")}
|
||||
<th class="h-7 px-3 font-medium">Date</th>
|
||||
{/if}
|
||||
{#if show("from")}
|
||||
<th class="h-7 px-3 font-medium">From</th>
|
||||
{/if}
|
||||
{#if show("to")}
|
||||
<th class="h-7 px-3 font-medium">To</th>
|
||||
{/if}
|
||||
{#if show("budget")}
|
||||
<th class="h-7 px-3 font-medium">Budget</th>
|
||||
{/if}
|
||||
{#if show("category")}
|
||||
<th class="h-7 px-3 font-medium">Category</th>
|
||||
{/if}
|
||||
{#if show("bill")}
|
||||
<th class="h-7 px-3 font-medium">Subscription</th>
|
||||
{/if}
|
||||
{#if show("created_at")}
|
||||
<th class="h-7 px-3 font-medium">Created at</th>
|
||||
{/if}
|
||||
{#if show("updated_at")}
|
||||
<th class="h-7 px-3 font-medium">Updated at</th>
|
||||
{/if}
|
||||
{#if show("notes")}
|
||||
<th class="h-7 px-3 font-medium">Notes</th>
|
||||
{/if}
|
||||
{#each dateColumns as key (key)}
|
||||
<th class="h-7 px-3 font-medium">
|
||||
{key.replace("_date", "").replace("process", "processing")}
|
||||
date
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.split.transaction_journal_id)}
|
||||
{@const s = row.split}
|
||||
{@const Icon =
|
||||
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
|
||||
>
|
||||
{#if show("icon")}
|
||||
<td class="px-2 py-1.5 text-cladd-fg-soft">
|
||||
<Icon aria-label={s.type} class="size-3.5" />
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("description")}
|
||||
<td class="max-w-[22rem] px-3 py-1">
|
||||
<a
|
||||
class="hover:underline"
|
||||
href="/transactions/show/{row.group}"
|
||||
>
|
||||
{#if row.title}
|
||||
{row.title}
|
||||
({s.description})
|
||||
{:else}
|
||||
{s.description}
|
||||
{/if}
|
||||
</a>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("balance_before")}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
|
||||
>
|
||||
{money(code, row.before)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("amount")}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
|
||||
0
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>
|
||||
{money(code, row.amount)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("balance_after")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-right">
|
||||
{money(code, row.after)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("date")}
|
||||
<td class="num whitespace-nowrap px-3 py-1">
|
||||
{formatDate(s.date, "medium")}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("from")}
|
||||
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||
<a class="hover:underline" href="/accounts/show/{s.source_id}"
|
||||
>{s.source_name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("to")}
|
||||
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||
<a
|
||||
class="hover:underline"
|
||||
href="/accounts/show/{s.destination_id}"
|
||||
>{s.destination_name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("budget")}
|
||||
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("category")}
|
||||
<td class="px-3 py-1">{s.category_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("bill")}
|
||||
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
|
||||
{/if}
|
||||
{#if show("created_at")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||
{formatDateTime(row.created)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("updated_at")}
|
||||
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||
{formatDateTime(row.updated)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if show("notes")}
|
||||
<td
|
||||
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
{s.notes ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
{#each dateColumns as key (key)}
|
||||
<td class="num whitespace-nowrap px-3 py-1">
|
||||
{formatDate(s[key] ?? null, "medium")}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
|
||||
Account balance of <strong>{account?.name}</strong> at the end of
|
||||
{formatDate(p.end, "long")}
|
||||
was:
|
||||
<span class="num font-semibold">{money(code, after ?? 0)}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</Box>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import {
|
||||
AUDIT_COLUMNS,
|
||||
type AuditColumn,
|
||||
DEFAULT_AUDIT,
|
||||
} from "#lib/reports/audit.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { page } from "$app/state";
|
||||
import AuditAccount from "./AuditAccount.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const raw = page.url.searchParams.get("cols");
|
||||
if (raw === null) {
|
||||
return DEFAULT_AUDIT;
|
||||
}
|
||||
const keys = new Set(raw.split(","));
|
||||
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
|
||||
});
|
||||
const accounts = $derived(
|
||||
report.selected
|
||||
.map((id) => ({ account: report.byId().get(id), id }))
|
||||
.filter((a) => a.account)
|
||||
.sort((a, b) =>
|
||||
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
|
||||
)
|
||||
);
|
||||
|
||||
function toggle(key: AuditColumn, on: boolean) {
|
||||
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
|
||||
k === key ? on : columns.includes(k)
|
||||
);
|
||||
navigate({ cols: next.join(",") });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Box body="px-3 py-2" title="Options">
|
||||
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<legend class="sr-only">Visible columns</legend>
|
||||
{#each AUDIT_COLUMNS as column (column.key)}
|
||||
<label class="flex h-7 items-center gap-1.5 text-[13px]">
|
||||
<input
|
||||
checked={columns.includes(column.key)}
|
||||
class="size-3.5 accent-[var(--ff-brand)]"
|
||||
onchange={(event) =>
|
||||
toggle(column.key, event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
{column.label}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</Box>
|
||||
{#if !report.accountsReady}
|
||||
<p class="text-cladd-fg-soft">Loading</p>
|
||||
{/if}
|
||||
{#each accounts as item (item.id)}
|
||||
<AuditAccount
|
||||
account={item.account}
|
||||
after={balances.data?.after.get(item.id)}
|
||||
before={balances.data?.before.get(item.id)}
|
||||
{columns}
|
||||
id={item.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import type { LineSeries } from "#lib/components/charts/chart.js";
|
||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||
import { contains, today } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { modeAxis } from "#lib/reports/view.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let { net = false }: { net?: boolean } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const ids = $derived(
|
||||
report.selected.filter(
|
||||
(id) => !net || report.byId().get(id)?.include_net_worth !== false
|
||||
)
|
||||
);
|
||||
const period = $derived.by(() => {
|
||||
if (!net) {
|
||||
return "1D";
|
||||
}
|
||||
return report.template === "multi-year" ? "1M" : "1W";
|
||||
});
|
||||
const chart = createQuery(() => ({
|
||||
...reportQueries.chart(p.start, p.end, period, ids),
|
||||
enabled: report.accountsReady && ids.length > 0,
|
||||
}));
|
||||
|
||||
const charts = $derived.by(() => {
|
||||
const byCode = new Map<string, LineSeries[]>();
|
||||
for (const set of chart.data ?? []) {
|
||||
const code = set.currency_code ?? "";
|
||||
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
|
||||
x: x.slice(0, 10),
|
||||
y: Number(y),
|
||||
}));
|
||||
const list = byCode.get(code) ?? [];
|
||||
if (net && list.length > 0) {
|
||||
const [sum] = list;
|
||||
sum.points = sum.points.map((point, i) => ({
|
||||
x: point.x,
|
||||
y: point.y + (points[i]?.y ?? 0),
|
||||
}));
|
||||
} else {
|
||||
list.push({
|
||||
format: (value: number) => money(code, value),
|
||||
key: net ? code : (set.label ?? code),
|
||||
label: net ? `Net worth in ${code}` : (set.label ?? code),
|
||||
points,
|
||||
});
|
||||
}
|
||||
byCode.set(code, list);
|
||||
}
|
||||
return [...byCode.entries()].sort(
|
||||
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
const marker = $derived(contains(p, today()) ? today() : undefined);
|
||||
const xFormat = $derived(
|
||||
report.template === "multi-year"
|
||||
? (x: string) => formatDate(x, "month")
|
||||
: (x: string) => formatDate(x, "short")
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={charts.length === 0}
|
||||
emptyText="No accounts to chart."
|
||||
id={net ? "net-worth" : "balance-chart"}
|
||||
loading={chart.isPending && ids.length > 0}
|
||||
title={net ? "Net worth" : "Account balances"}
|
||||
>
|
||||
<div class="grid gap-x-6 gap-y-4 {charts.length > 1 ? "xl:grid-cols-2" : ""}">
|
||||
{#each charts as [code, series] (code)}
|
||||
<div class="min-w-0">
|
||||
{#if charts.length > 1 || !net}
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{code}
|
||||
</h3>
|
||||
{/if}
|
||||
<LineChart
|
||||
axisFormat={modeAxis(code)}
|
||||
height={charts.length > 1 ? 200 : 240}
|
||||
label="{net ? "Net worth" : "Account balances"} in {code}"
|
||||
{marker}
|
||||
{series}
|
||||
{xFormat}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { formatRange, percent } from "#lib/format.js";
|
||||
import { NONE } from "#lib/reports/flows.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let { class: className = "" }: { class?: string } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const limits = createQuery(() => reportQueries.limits(p.start, p.end));
|
||||
|
||||
interface Line {
|
||||
budgeted: number | null;
|
||||
budgetId: string;
|
||||
code: string;
|
||||
end: string;
|
||||
key: string;
|
||||
name: string;
|
||||
spent: number;
|
||||
start: string;
|
||||
}
|
||||
|
||||
const lines = $derived.by(() => {
|
||||
const { flows } = report;
|
||||
if (!(flows && limits.data)) {
|
||||
return [];
|
||||
}
|
||||
const withdrawals = flows.filter(
|
||||
(f) => f.dir === -1 && f.type === "withdrawal"
|
||||
);
|
||||
const result: Line[] = limits.data.map((item) => {
|
||||
const a = item.attributes;
|
||||
const start = (a.start ?? p.start).slice(0, 10);
|
||||
const end = (a.end ?? p.end).slice(0, 10);
|
||||
const code = a.currency_code ?? "";
|
||||
const spent = withdrawals
|
||||
.filter(
|
||||
(f) =>
|
||||
f.budget?.id === String(a.budget_id) &&
|
||||
f.currency === code &&
|
||||
f.date >= start &&
|
||||
f.date <= end
|
||||
)
|
||||
.reduce((sum, f) => sum + f.amount, 0);
|
||||
return {
|
||||
budgeted: Number(a.amount ?? 0),
|
||||
budgetId: String(a.budget_id),
|
||||
code,
|
||||
end,
|
||||
key: item.id,
|
||||
name:
|
||||
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
||||
spent,
|
||||
start,
|
||||
};
|
||||
});
|
||||
result.sort(
|
||||
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
||||
);
|
||||
const none = new Map<string, number>();
|
||||
for (const f of withdrawals) {
|
||||
if (!f.budget) {
|
||||
none.set(f.currency, (none.get(f.currency) ?? 0) + f.amount);
|
||||
}
|
||||
}
|
||||
for (const [code, spent] of none) {
|
||||
result.push({
|
||||
budgeted: null,
|
||||
budgetId: NONE,
|
||||
code,
|
||||
end: p.end,
|
||||
key: `none-${code}`,
|
||||
name: `(no budget) (${code})`,
|
||||
spent,
|
||||
start: p.start,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const sums = $derived.by(() => {
|
||||
const map = new Map<string, { budgeted: number; spent: number }>();
|
||||
for (const line of lines) {
|
||||
const sum = map.get(line.code) ?? { budgeted: 0, spent: 0 };
|
||||
sum.budgeted += line.budgeted ?? 0;
|
||||
sum.spent += line.spent;
|
||||
map.set(line.code, sum);
|
||||
}
|
||||
return [...map.entries()].sort(
|
||||
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
const sumOf = (code: string) => sums.find(([c]) => c === code)?.[1];
|
||||
|
||||
function open(line: Line) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "budgets" && current.key === line.budgetId;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "budgets",
|
||||
dim: "budget",
|
||||
dir: -1,
|
||||
key: line.budgetId,
|
||||
label: line.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
class={className}
|
||||
empty={lines.length === 0}
|
||||
emptyText="No budgets or spending in this period."
|
||||
id="budgets"
|
||||
loading={!(report.flows && limits.data && report.budgets)}
|
||||
title="Budgets"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Budget</th>
|
||||
<th class="h-7 px-3 text-left font-medium max-2xl:hidden">Date</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Budgeted</th>
|
||||
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Spent</th>
|
||||
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Left</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Overspent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lines as line (line.key)}
|
||||
{@const sum = sumOf(line.code)}
|
||||
{@const left = (line.budgeted ?? 0) - line.spent}
|
||||
{@const active =
|
||||
report.drill?.box === "budgets" &&
|
||||
report.drill.key === line.budgetId}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[14rem] px-3">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => open(line)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{line.name}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 text-cladd-fg-soft max-2xl:hidden"
|
||||
>
|
||||
{#if line.budgeted !== null}
|
||||
{formatRange(line.start, line.end)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right">
|
||||
{#if line.budgeted !== null}
|
||||
{money(line.code, line.budgeted)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
|
||||
{#if line.budgeted && sum?.budgeted}
|
||||
{percent(line.budgeted / sum.budgeted)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{line.spent ? money(line.code, -line.spent) : ""}
|
||||
</td>
|
||||
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
|
||||
{#if line.spent && sum?.spent}
|
||||
{percent(line.spent / sum.spent)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-income">
|
||||
{#if line.budgeted !== null && left > 0}
|
||||
{money(line.code, left)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{#if line.budgeted !== null && left < 0}
|
||||
{money(line.code, -left)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{#each sums as [code, sum], i (code)}
|
||||
<tr
|
||||
class="h-7 font-semibold {i === 0
|
||||
? "border-cladd-outline border-t-2"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">Sum ({code})</td>
|
||||
<td class="max-2xl:hidden"></td>
|
||||
<td class="num px-3 text-right">{money(code, sum.budgeted)}</td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{money(code, -sum.spent)}
|
||||
</td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
|
||||
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { bucketLabel } from "#lib/reports/view.js";
|
||||
import AccountBalances from "./AccountBalances.svelte";
|
||||
import BalanceLines from "./BalanceLines.svelte";
|
||||
import BudgetLimits from "./BudgetLimits.svelte";
|
||||
import DeltaToggle from "./DeltaToggle.svelte";
|
||||
import IncomeVsExpenses from "./IncomeVsExpenses.svelte";
|
||||
import Matrix from "./Matrix.svelte";
|
||||
import MoneyFlow from "./MoneyFlow.svelte";
|
||||
import PeriodChart from "./PeriodChart.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
import ReportRow from "./ReportRow.svelte";
|
||||
import SpendingCalendar from "./SpendingCalendar.svelte";
|
||||
import Subscriptions from "./Subscriptions.svelte";
|
||||
import SumTable from "./SumTable.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const flows = $derived(report.flows);
|
||||
const month = $derived(report.template === "month");
|
||||
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
|
||||
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
|
||||
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
|
||||
const deposits = $derived(income.filter((f) => f.type === "deposit"));
|
||||
const bucket = (date: string) => bucketOf(date, report.grain);
|
||||
const periodCols = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain).map(
|
||||
(key) => ({
|
||||
key,
|
||||
label: bucketLabel(key, report.grain, true),
|
||||
title: bucketLabel(key, report.grain),
|
||||
})
|
||||
)
|
||||
);
|
||||
const accountCols = $derived(
|
||||
report.selected
|
||||
.filter((id) => withdrawals.some((f) => f.asset.id === id))
|
||||
.map((id) => ({
|
||||
key: id,
|
||||
label: report.byId().get(id)?.name ?? `#${id}`,
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if month}
|
||||
<BalanceLines />
|
||||
{:else}
|
||||
<PeriodChart {flows} />
|
||||
{/if}
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<AccountBalances />
|
||||
<IncomeVsExpenses />
|
||||
</ReportRow>
|
||||
|
||||
<MoneyFlow />
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={income.length === 0}
|
||||
id="income"
|
||||
loading={!flows}
|
||||
title="Revenue / income"
|
||||
>
|
||||
<SumTable
|
||||
box="income"
|
||||
columns={[
|
||||
{ kind: "in", label: "Total" },
|
||||
{ kind: "avg-in", label: "Average" },
|
||||
]}
|
||||
counts
|
||||
dim="other"
|
||||
dir={1}
|
||||
footer={total(income)}
|
||||
limit={10}
|
||||
rows={group(income, keys.other)}
|
||||
sort="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={expenses.length === 0}
|
||||
id="expenses"
|
||||
loading={!flows}
|
||||
title="Expenses"
|
||||
>
|
||||
<SumTable
|
||||
box="expenses"
|
||||
columns={[
|
||||
{ kind: "out", label: "Total" },
|
||||
{ kind: "avg-out", label: "Average" },
|
||||
]}
|
||||
counts
|
||||
dim="other"
|
||||
dir={-1}
|
||||
footer={total(expenses)}
|
||||
limit={10}
|
||||
rows={group(expenses, keys.other)}
|
||||
sort="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
{#if month}
|
||||
<ReportRow class="lg:grid-cols-12">
|
||||
<BudgetLimits class="lg:col-span-7" />
|
||||
<ReportBox
|
||||
class="lg:col-span-5"
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="categories"
|
||||
loading={!flows}
|
||||
title="Categories"
|
||||
>
|
||||
<SumTable
|
||||
box="categories"
|
||||
columns={[
|
||||
{ kind: "out", label: "Spent" },
|
||||
{ kind: "in", label: "Earned" },
|
||||
{ kind: "net", label: "Sum" },
|
||||
]}
|
||||
dim="category"
|
||||
footer={report.totals}
|
||||
limit={10}
|
||||
nameLabel="Category"
|
||||
rows={group(flows ?? [], keys.category)}
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
<ReportBox
|
||||
empty={accountCols.length === 0}
|
||||
id="budget-accounts"
|
||||
loading={!flows}
|
||||
title="Budgets (split by account)"
|
||||
>
|
||||
<Matrix
|
||||
box="budget-accounts"
|
||||
colIs="asset"
|
||||
cols={accountCols}
|
||||
dim="budget"
|
||||
dir={-1}
|
||||
multi
|
||||
nameLabel="Budget"
|
||||
rows={group(withdrawals, keys.budget, (f) => f.asset.id)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="tags"
|
||||
loading={!flows}
|
||||
title="Tags"
|
||||
>
|
||||
<SumTable
|
||||
box="tags"
|
||||
columns={[
|
||||
{ kind: "out", label: "Spent" },
|
||||
{ kind: "in", label: "Earned" },
|
||||
]}
|
||||
dim="tag"
|
||||
footer={report.totals}
|
||||
limit={10}
|
||||
nameLabel="Tag"
|
||||
rows={group(flows ?? [], keys.tag)}
|
||||
/>
|
||||
</ReportBox>
|
||||
<SpendingCalendar />
|
||||
</ReportRow>
|
||||
|
||||
<Subscriptions />
|
||||
{:else}
|
||||
<BalanceLines net />
|
||||
|
||||
<ReportBox
|
||||
empty={withdrawals.length === 0}
|
||||
id="budget-periods"
|
||||
loading={!flows}
|
||||
title="Budgets ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="budget-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
average
|
||||
box="budget-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "budget-periods"}
|
||||
dim="budget"
|
||||
dir={-1}
|
||||
nameLabel="Budget"
|
||||
rows={group(withdrawals, keys.budget, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={expenses.length === 0}
|
||||
id="category-expenses"
|
||||
loading={!flows}
|
||||
title="Categories (Expenses) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="category-expenses" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="category-expenses"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "category-expenses"}
|
||||
dim="category"
|
||||
dir={-1}
|
||||
nameLabel="Category"
|
||||
rows={group(withdrawals, keys.category, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={deposits.length === 0}
|
||||
id="category-income"
|
||||
loading={!flows}
|
||||
title="Categories (Revenue / income) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="category-income" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="category-income"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "category-income"}
|
||||
dim="category"
|
||||
dir={1}
|
||||
nameLabel="Category"
|
||||
rows={group(deposits, keys.category, (f) => bucket(f.date))}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="tag-periods"
|
||||
loading={!flows}
|
||||
title="Tags (Expenses) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="tag-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="tag-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "tag-periods"}
|
||||
dim="tag"
|
||||
dir={-1}
|
||||
nameLabel="Tag"
|
||||
rows={group(expenses, keys.tag, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<SpendingCalendar />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import { percent } from "#lib/format.js";
|
||||
|
||||
let {
|
||||
now,
|
||||
before,
|
||||
upIsGood,
|
||||
title,
|
||||
}: {
|
||||
now: number;
|
||||
before: number | undefined;
|
||||
upIsGood: boolean;
|
||||
title?: string;
|
||||
} = $props();
|
||||
|
||||
const ratio = $derived(
|
||||
before ? (Math.abs(now) - Math.abs(before)) / Math.abs(before) : null
|
||||
);
|
||||
const up = $derived((ratio ?? 0) > 0);
|
||||
const tone = $derived(up === upIsGood ? "text-income" : "text-expense");
|
||||
</script>
|
||||
|
||||
{#if ratio !== null && Math.abs(ratio) >= 0.005}
|
||||
<span class="num inline-flex items-center gap-0.5 text-[11px] {tone}" {title}>
|
||||
{#if up}
|
||||
<ArrowUp aria-label="up" class="size-3" />
|
||||
{:else}
|
||||
<ArrowDown aria-label="down" class="size-3" />
|
||||
{/if}
|
||||
{percent(Math.abs(ratio))}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let { box }: { box: string } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const on = $derived(report.delta === box);
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-pressed={on}
|
||||
class="h-6 rounded-cladd-sm px-2 text-[12px] {on
|
||||
? "cladd-cut font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => navigate({ delta: on ? null : box })}
|
||||
title={on ? "Show amounts" : "Show change against the previous period"}
|
||||
type="button"
|
||||
>
|
||||
{on ? "Amounts" : "Change"}
|
||||
</button>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
bucketOf,
|
||||
buckets,
|
||||
type Dimension,
|
||||
type Flow,
|
||||
group,
|
||||
type Key,
|
||||
keys,
|
||||
topFlows,
|
||||
total,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
LIST_LENGTH,
|
||||
modeLabel,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import { bucketLabel } from "#lib/reports/view.js";
|
||||
import DeltaToggle from "./DeltaToggle.svelte";
|
||||
import FlowTable from "./FlowTable.svelte";
|
||||
import Matrix from "./Matrix.svelte";
|
||||
import PeriodChart from "./PeriodChart.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
import ReportRow from "./ReportRow.svelte";
|
||||
import ShareBars from "./ShareBars.svelte";
|
||||
import SumTable from "./SumTable.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const type = $derived(report.params.type);
|
||||
const flows = $derived(report.scoped);
|
||||
const loading = $derived(!flows);
|
||||
const out = $derived(flows?.filter((f) => f.dir === -1) ?? []);
|
||||
const inn = $derived(flows?.filter((f) => f.dir === 1) ?? []);
|
||||
const hasIncome = $derived(type !== "budget" && inn.length > 0);
|
||||
|
||||
const tagNames = $derived(
|
||||
new Set(
|
||||
report.items
|
||||
.map((id) => report.itemName("tag", id))
|
||||
.filter((name): name is string => !!name)
|
||||
)
|
||||
);
|
||||
|
||||
const itemDim = $derived<Dimension>(
|
||||
(
|
||||
{
|
||||
budget: "budget",
|
||||
category: "category",
|
||||
double: "name",
|
||||
tag: "tag",
|
||||
} as const
|
||||
)[type as "budget" | "category" | "double" | "tag"]
|
||||
);
|
||||
const itemKey = $derived.by(() => {
|
||||
if (type === "tag") {
|
||||
return (flow: Flow) => keys.tag(flow).filter((k) => tagNames.has(k.key));
|
||||
}
|
||||
return keys[itemDim] as (flow: Flow) => Key | Key[] | null;
|
||||
});
|
||||
const NAMES = {
|
||||
budget: { many: "Budgets", one: "Budget" },
|
||||
category: { many: "Categories", one: "Category" },
|
||||
double: { many: "Expense/revenue accounts", one: "Account" },
|
||||
tag: { many: "Tags", one: "Tag" },
|
||||
} as const;
|
||||
const names = $derived(NAMES[type as keyof typeof NAMES]);
|
||||
|
||||
const items = $derived.by(() => {
|
||||
if (type === "double") {
|
||||
const seen = new Map<string, string>();
|
||||
for (const flow of flows ?? []) {
|
||||
const key = keys.name(flow);
|
||||
seen.set(key.key, key.label);
|
||||
}
|
||||
return [...seen.entries()].map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
return report.items.map((id) => {
|
||||
const kind = type === "tag" ? "tag" : type;
|
||||
const label = report.itemName(kind, id) ?? `#${id}`;
|
||||
return { key: type === "tag" ? label : id, label };
|
||||
});
|
||||
});
|
||||
|
||||
const itemRows = $derived(group(flows ?? [], itemKey));
|
||||
const accountRows = $derived(group(flows ?? [], keys.asset));
|
||||
const accountMatrix = $derived(
|
||||
group(flows ?? [], keys.asset, (f) => {
|
||||
const found = itemKey(f);
|
||||
const first = Array.isArray(found) ? found[0] : found;
|
||||
return first?.key ?? null;
|
||||
})
|
||||
);
|
||||
const periodCols = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain).map(
|
||||
(key) => ({
|
||||
key,
|
||||
label: bucketLabel(key, report.grain, true),
|
||||
title: bucketLabel(key, report.grain),
|
||||
})
|
||||
)
|
||||
);
|
||||
const top = $derived((dir: 1 | -1) =>
|
||||
topFlows(flows ?? [], report.mode, dir, LIST_LENGTH)
|
||||
);
|
||||
const sumColumns = $derived(
|
||||
hasIncome
|
||||
? [
|
||||
{ kind: "out" as const, label: "Spent" },
|
||||
{ kind: "in" as const, label: "Earned" },
|
||||
{ kind: "net" as const, label: "Sum" },
|
||||
]
|
||||
: [{ kind: "out" as const, label: "Spent" }]
|
||||
);
|
||||
const itemColumns = $derived(
|
||||
hasIncome
|
||||
? sumColumns
|
||||
: [
|
||||
{ kind: "out" as const, label: "Spent" },
|
||||
{ kind: "pct-out" as const, label: "pct." },
|
||||
]
|
||||
);
|
||||
const flowColumns = $derived(
|
||||
type === "double"
|
||||
? ["date", "description", "account", "other"]
|
||||
: [
|
||||
"date",
|
||||
"description",
|
||||
"account",
|
||||
"other",
|
||||
type === "budget" ? "budget" : "category",
|
||||
]
|
||||
);
|
||||
const mode = $derived(modeLabel(report.mode));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={accountRows.length === 0}
|
||||
id="accounts"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Earned and spent (per asset account)"
|
||||
: "Accounts"}
|
||||
>
|
||||
<SumTable
|
||||
box="accounts"
|
||||
columns={sumColumns}
|
||||
dim="asset"
|
||||
footer={report.totals}
|
||||
rows={accountRows}
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={itemRows.length === 0}
|
||||
id="items"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Earned and spent per combination"
|
||||
: names.many}
|
||||
>
|
||||
<SumTable
|
||||
box="items"
|
||||
columns={itemColumns}
|
||||
dim={itemDim}
|
||||
footer={report.totals}
|
||||
nameLabel={names.one}
|
||||
rows={itemRows}
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
{#if type !== "double"}
|
||||
<ReportBox
|
||||
empty={accountMatrix.length === 0}
|
||||
id="account-matrix"
|
||||
{loading}
|
||||
title="Account per {names.one.toLowerCase()}"
|
||||
>
|
||||
<Matrix
|
||||
box="account-matrix"
|
||||
colIs="item"
|
||||
cols={items}
|
||||
dim="asset"
|
||||
{itemDim}
|
||||
multi
|
||||
nameLabel="Account"
|
||||
rows={accountMatrix}
|
||||
side={hasIncome ? "net" : "out"}
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
|
||||
<ReportBox
|
||||
empty={itemRows.length === 0}
|
||||
id="item-periods"
|
||||
{loading}
|
||||
title="{names.many} per period ({mode})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="item-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
average
|
||||
box="item-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "item-periods"}
|
||||
dim={itemDim}
|
||||
dir={-1}
|
||||
nameLabel={names.one}
|
||||
rows={group(out, itemKey, (f) => bucketOf(f.date, report.grain))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportRow class={type === "budget" ? "lg:grid-cols-2" : "lg:grid-cols-3"}>
|
||||
{#if type === "tag"}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-item-out"
|
||||
{loading}
|
||||
title="Expense per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-item-out"
|
||||
dim="tag"
|
||||
rows={group(out, itemKey)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-item-in"
|
||||
{loading}
|
||||
title="Income per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-item-in"
|
||||
dim="tag"
|
||||
rows={group(inn, itemKey)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<div class="max-lg:hidden"></div>
|
||||
{/if}
|
||||
{#if type === "budget"}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-budget"
|
||||
{loading}
|
||||
title="Expense per budget ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-budget"
|
||||
dim="budget"
|
||||
rows={group(out, keys.budget)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-category-out"
|
||||
{loading}
|
||||
title="Expense per category ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-category-out"
|
||||
dim="category"
|
||||
rows={group(out, keys.category)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-category-in"
|
||||
{loading}
|
||||
title="Income per category ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-category-in"
|
||||
dim="category"
|
||||
rows={group(inn, keys.category)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-budget"
|
||||
{loading}
|
||||
title="Expense per budget ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-budget"
|
||||
dim="budget"
|
||||
rows={group(out, keys.budget)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
|
||||
{#if type === "double"}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-tag-out"
|
||||
{loading}
|
||||
title="Expense per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-tag-out"
|
||||
dim="tag"
|
||||
rows={group(out, keys.tag)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-tag-in"
|
||||
{loading}
|
||||
title="Income per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-tag-in"
|
||||
dim="tag"
|
||||
rows={group(inn, keys.tag)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
{:else}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-source"
|
||||
{loading}
|
||||
title="Expenses per source account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-source"
|
||||
dim="asset"
|
||||
rows={group(out, keys.asset)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-income-source"
|
||||
{loading}
|
||||
title="Income per source account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-income-source"
|
||||
dim="other"
|
||||
rows={group(inn, keys.other)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-destination"
|
||||
{loading}
|
||||
title="Expenses per destination account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-destination"
|
||||
dim="other"
|
||||
rows={group(out, keys.other)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-income-destination"
|
||||
{loading}
|
||||
title="Income per destination account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-income-destination"
|
||||
dim="asset"
|
||||
rows={group(inn, keys.asset)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
{/if}
|
||||
|
||||
{#each items as item (item.key)}
|
||||
{@const subset = flows?.filter((f) => {
|
||||
const found = itemKey(f);
|
||||
return Array.isArray(found)
|
||||
? found.some((k) => k.key === item.key)
|
||||
: found?.key === item.key;
|
||||
})}
|
||||
{#if !subset || subset.length > 0}
|
||||
<PeriodChart
|
||||
dim={itemDim}
|
||||
flows={subset}
|
||||
id="item-{item.key}"
|
||||
itemKey={item.key}
|
||||
title={type === "budget"
|
||||
? `Expenses (${item.label}),`
|
||||
: `Income and expenses (${item.label}),`}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="avg-out"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Average expense per source account"
|
||||
: "Average expense per destination account"}
|
||||
>
|
||||
<SumTable
|
||||
averageAll
|
||||
box="avg-out"
|
||||
columns={[
|
||||
{ kind: "avg-out", label: "Spent (average)" },
|
||||
{ kind: "out", label: "Total" },
|
||||
{ kind: "count", label: "Transaction count" },
|
||||
]}
|
||||
dim={type === "double" ? "asset" : "other"}
|
||||
dir={-1}
|
||||
limit={LIST_LENGTH}
|
||||
nameLabel="Account"
|
||||
rows={group(out, type === "double" ? keys.asset : keys.other)}
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="avg-in"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Average earning per destination account"
|
||||
: "Average earning per source account"}
|
||||
>
|
||||
<SumTable
|
||||
averageAll
|
||||
box="avg-in"
|
||||
columns={[
|
||||
{ kind: "avg-in", label: "Income (average)" },
|
||||
{ kind: "in", label: "Total" },
|
||||
{ kind: "count", label: "Transaction count" },
|
||||
]}
|
||||
dim={type === "double" ? "asset" : "other"}
|
||||
dir={1}
|
||||
limit={LIST_LENGTH}
|
||||
nameLabel="Account"
|
||||
rows={group(inn, type === "double" ? keys.asset : keys.other)}
|
||||
sort="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{:else}
|
||||
<ReportBox
|
||||
empty={top(-1).length === 0}
|
||||
id="top-out"
|
||||
{loading}
|
||||
title="Expenses (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(-1)} />
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
|
||||
{#if hasIncome}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={top(-1).length === 0}
|
||||
id="top-out"
|
||||
{loading}
|
||||
title="Expenses (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(-1)} />
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={top(1).length === 0}
|
||||
id="top-in"
|
||||
{loading}
|
||||
title="Revenue / income (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(1)} />
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import Columns from "#lib/components/charts/Columns.svelte";
|
||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { bucketOf, buckets, total } from "#lib/reports/flows.js";
|
||||
import {
|
||||
type Drill,
|
||||
drillFilter,
|
||||
drillQuery,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import {
|
||||
bucketLabel,
|
||||
bucketRows,
|
||||
FLOW_KEYS,
|
||||
modeAxis,
|
||||
modeFormat,
|
||||
upright,
|
||||
} from "#lib/reports/view.js";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
let { drill }: { drill: Drill } = $props();
|
||||
|
||||
const report = useReport();
|
||||
|
||||
const subset = $derived(
|
||||
(report.scoped ?? [])
|
||||
.filter(drillFilter(drill, (date) => bucketOf(date, report.grain)))
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.amount - a.amount)
|
||||
);
|
||||
const totals = $derived(total(subset));
|
||||
const groups = $derived.by(() => {
|
||||
const ids = new Set(subset.map((flow) => flow.group));
|
||||
return (report.groups ?? []).filter((group) => ids.has(group.id));
|
||||
});
|
||||
const showChart = $derived(
|
||||
!drill.at &&
|
||||
drill.dim !== "day" &&
|
||||
drill.dim !== "flow" &&
|
||||
report.grain !== "day" &&
|
||||
subset.length > 0
|
||||
);
|
||||
const rows = $derived(
|
||||
showChart
|
||||
? upright(
|
||||
bucketRows(
|
||||
subset,
|
||||
buckets(report.params.start, report.params.end, report.grain),
|
||||
report.grain,
|
||||
report.mode
|
||||
)
|
||||
)
|
||||
: []
|
||||
);
|
||||
const keys = $derived(
|
||||
FLOW_KEYS.filter((key) =>
|
||||
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
|
||||
)
|
||||
);
|
||||
const title = $derived(
|
||||
[drill.label, drill.at ? bucketLabel(drill.at, report.grain) : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
function close() {
|
||||
navigate(drillQuery(null));
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "Escape",
|
||||
label: "Close transactions list",
|
||||
run: close,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section
|
||||
aria-label="Transactions for {title}"
|
||||
class="border-cladd-outline border-t bg-cladd-surface-next/50"
|
||||
>
|
||||
<header class="flex flex-wrap items-start gap-x-4 gap-y-1 px-3 pt-2.5 pb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-semibold text-[13px]">{title}</h3>
|
||||
<p class="text-[12px] text-cladd-fg-soft">
|
||||
{subset.length}
|
||||
{subset.length === 1 ? "transaction" : "transactions"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4 text-right text-[12px]">
|
||||
{#each [
|
||||
{ label: "In", side: "in" as const },
|
||||
{ label: "Out", side: "out" as const },
|
||||
] as column (column.side)}
|
||||
{#if [...totals.cur.values()].some((m) => m[column.side] > 0)}
|
||||
<div>
|
||||
<div class="text-cladd-fg-soft">{column.label}</div>
|
||||
<Money codes={report.codes} side={column.side} {totals} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={close}
|
||||
title="Close Esc"
|
||||
type="button"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
{#if showChart && keys.length > 0}
|
||||
<div class="px-3 pb-2">
|
||||
<Columns
|
||||
axisFormat={modeAxis(report.mode)}
|
||||
format={modeFormat(report.mode)}
|
||||
height={180}
|
||||
{keys}
|
||||
label="{title} per period"
|
||||
onselect={(row) => navigate(drillQuery({ ...drill, at: row.key }))}
|
||||
{rows}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if subset.length > 0}
|
||||
<TransactionTable {groups} id="drill" />
|
||||
{:else}
|
||||
<p class="px-3 pb-3 text-cladd-fg-soft">No transactions match.</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import type { Flow } from "#lib/reports/flows.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
flows,
|
||||
columns = ["date", "description", "account", "other", "category", "budget"],
|
||||
}: {
|
||||
flows: Flow[];
|
||||
columns?: string[];
|
||||
} = $props();
|
||||
|
||||
const has = (name: string) => columns.includes(name);
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-[12px] text-cladd-fg-soft">
|
||||
{#if has("date")}
|
||||
<th class="h-7 px-3 font-medium">Date</th>
|
||||
{/if}
|
||||
<th class="h-7 px-3 font-medium">Description</th>
|
||||
{#if has("account")}
|
||||
<th class="h-7 px-3 font-medium max-md:hidden">Account</th>
|
||||
{/if}
|
||||
{#if has("other")}
|
||||
<th class="h-7 px-3 font-medium max-md:hidden">Counterparty</th>
|
||||
{/if}
|
||||
{#if has("category")}
|
||||
<th class="h-7 px-3 font-medium max-lg:hidden">Category</th>
|
||||
{/if}
|
||||
{#if has("budget")}
|
||||
<th class="h-7 px-3 font-medium max-lg:hidden">Budget</th>
|
||||
{/if}
|
||||
<th class="h-7 px-3 text-right font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each flows as flow (`${flow.journal}:${flow.dir}`)}
|
||||
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
|
||||
{#if has("date")}
|
||||
<td class="num whitespace-nowrap px-3 text-cladd-fg-soft">
|
||||
{formatDate(flow.date, "short")}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="max-w-[22rem] truncate px-3">
|
||||
<a class="hover:underline" href="/transactions/show/{flow.group}"
|
||||
>{flow.description}</a
|
||||
>
|
||||
</td>
|
||||
{#if has("account")}
|
||||
<td
|
||||
class="max-w-[12rem] truncate px-3 text-cladd-fg-soft max-md:hidden"
|
||||
>
|
||||
{flow.asset.name}
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("other")}
|
||||
<td class="max-w-[14rem] truncate px-3 max-md:hidden">
|
||||
<a class="hover:underline" href="/accounts/show/{flow.other.id}"
|
||||
>{flow.other.name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("category")}
|
||||
<td
|
||||
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
|
||||
>
|
||||
{flow.category?.name ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("budget")}
|
||||
<td
|
||||
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
|
||||
>
|
||||
{flow.budget?.name ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 text-right {flow.dir === 1
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>
|
||||
{money(flow.currency, flow.dir * flow.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { formatRange } from "#lib/format.js";
|
||||
import {
|
||||
currencies,
|
||||
measureOf,
|
||||
type Totals,
|
||||
toFlows,
|
||||
total,
|
||||
} from "#lib/reports/flows.js";
|
||||
import { shiftRange } from "#lib/reports/params.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import Delta from "./Delta.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const previous = $derived(shiftRange(report.params, -1));
|
||||
const earlier = createQuery(() =>
|
||||
reportQueries.transactions(previous.start, previous.end)
|
||||
);
|
||||
const before = $derived(
|
||||
earlier.data && report.accountsReady
|
||||
? total(
|
||||
toFlows(
|
||||
earlier.data,
|
||||
new Set(report.selected),
|
||||
(id) => report.byId().get(id)?.currency_code
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
const was = (code: string) =>
|
||||
before && (code !== "pc" || before.pcOk)
|
||||
? measureOf(before, code)
|
||||
: undefined;
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
const totals: Totals | undefined = report.totals;
|
||||
if (!totals) {
|
||||
return [];
|
||||
}
|
||||
const list = currencies(totals, report.codes).map((code) => {
|
||||
const m = totals.cur.get(code) ?? { in: 0, n: 0, out: 0 };
|
||||
return { code, in: m.in, label: code, out: m.out };
|
||||
});
|
||||
if (totals.pcOk && list.length > 1) {
|
||||
list.push({
|
||||
code: "pc",
|
||||
in: totals.pc.in,
|
||||
label: modeLabel("pc"),
|
||||
out: totals.pc.out,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function open(dir: 1 | -1) {
|
||||
const label = dir === 1 ? "Money in" : "Money out";
|
||||
const current = report.drill;
|
||||
const same = current?.box === "operations" && current.dir === dir;
|
||||
navigate(drillQuery(same ? null : { box: "operations", dir, label }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
empty={rows.length === 0}
|
||||
id="operations"
|
||||
loading={!report.totals}
|
||||
title="Income vs. expenses"
|
||||
>
|
||||
{#snippet tools()}
|
||||
{#if before && before.n > 0}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
>Change against {formatRange(previous.start, previous.end)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Currency</th>
|
||||
<th class="h-7 px-3 text-right font-medium">In</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Out</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.code)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {row.code ===
|
||||
"pc"
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">{row.label}</td>
|
||||
<td class="p-0 text-right">
|
||||
<button
|
||||
class="num h-7 w-full px-3 text-right text-income hover:bg-row-cursor"
|
||||
onclick={() => open(1)}
|
||||
title="Show money in"
|
||||
type="button"
|
||||
>
|
||||
<Delta
|
||||
before={was(row.code)?.in}
|
||||
now={row.in}
|
||||
title="Previous period: {money(
|
||||
row.code,
|
||||
was(row.code)?.in ?? 0
|
||||
)}"
|
||||
upIsGood
|
||||
/>
|
||||
{money(row.code, row.in)}
|
||||
</button>
|
||||
</td>
|
||||
<td class="p-0 text-right">
|
||||
<button
|
||||
class="num h-7 w-full px-3 text-right text-expense hover:bg-row-cursor"
|
||||
onclick={() => open(-1)}
|
||||
title="Show money out"
|
||||
type="button"
|
||||
>
|
||||
<Delta
|
||||
before={was(row.code)?.out}
|
||||
now={row.out}
|
||||
title="Previous period: {money(
|
||||
row.code,
|
||||
-(was(row.code)?.out ?? 0)
|
||||
)}"
|
||||
upIsGood={false}
|
||||
/>
|
||||
{money(row.code, -row.out)}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
class="num px-3 text-right {row.in - row.out < 0
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>
|
||||
{money(row.code, row.in - row.out, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import { formatNumber, percent } from "#lib/format.js";
|
||||
import {
|
||||
amountOf,
|
||||
type Dimension,
|
||||
merge,
|
||||
type Row,
|
||||
rank,
|
||||
type Side,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
type Drill,
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
interface Col {
|
||||
key: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
cols,
|
||||
side,
|
||||
dim,
|
||||
dir,
|
||||
colIs = "at",
|
||||
itemDim,
|
||||
delta = false,
|
||||
average = false,
|
||||
nameLabel = "Name",
|
||||
multi = false,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
cols: Col[];
|
||||
side: Side;
|
||||
dim: Dimension;
|
||||
dir?: 1 | -1;
|
||||
colIs?: "at" | "asset" | "item";
|
||||
itemDim?: Dimension;
|
||||
delta?: boolean;
|
||||
average?: boolean;
|
||||
nameLabel?: string;
|
||||
multi?: boolean;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
|
||||
const sorted = $derived(
|
||||
multi
|
||||
? rank(rows, mode, side)
|
||||
: rank(rows, mode, side).filter(
|
||||
(row) => amountOf(row.totals, mode, side) !== 0
|
||||
)
|
||||
);
|
||||
const colMerged = $derived(
|
||||
multi
|
||||
? cols.map((col) => merge(sorted.map((row) => row.cells.get(col.key))))
|
||||
: []
|
||||
);
|
||||
const sign = $derived(side === "out" ? -1 : 1);
|
||||
const compact = $derived(cols.length > 6);
|
||||
const short = (value: number) =>
|
||||
compact ? formatNumber(value, 0) : money(mode, value);
|
||||
const colTotals = $derived(
|
||||
cols.map((col) =>
|
||||
sorted.reduce(
|
||||
(sum, row) => sum + amountOf(row.cells.get(col.key), mode, side),
|
||||
0
|
||||
)
|
||||
)
|
||||
);
|
||||
const grand = $derived(colTotals.reduce((a, b) => a + b, 0));
|
||||
|
||||
function cellDrill(row: Row | null, col: Col | null): Drill {
|
||||
const label = [row?.label, col?.title ?? col?.label]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (colIs === "item") {
|
||||
return {
|
||||
asset: row && col ? row.key : undefined,
|
||||
box,
|
||||
dim: col ? itemDim : dim,
|
||||
dir,
|
||||
key: col ? col.key : row?.key,
|
||||
label,
|
||||
};
|
||||
}
|
||||
return {
|
||||
asset: colIs === "asset" ? col?.key : undefined,
|
||||
at: colIs === "at" ? col?.key : undefined,
|
||||
box,
|
||||
dim: row ? dim : undefined,
|
||||
dir,
|
||||
key: row?.key,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function isActive(row: Row | null, col: Col | null) {
|
||||
const current = report.drill;
|
||||
if (current?.box !== box) {
|
||||
return false;
|
||||
}
|
||||
const next = cellDrill(row, col);
|
||||
return (
|
||||
current.key === next.key &&
|
||||
current.at === next.at &&
|
||||
current.asset === next.asset
|
||||
);
|
||||
}
|
||||
|
||||
function open(row: Row | null, col: Col | null) {
|
||||
navigate(drillQuery(isActive(row, col) ? null : cellDrill(row, col)));
|
||||
}
|
||||
|
||||
function change(row: Row, index: number) {
|
||||
if (index === 0) {
|
||||
return null;
|
||||
}
|
||||
const now = amountOf(row.cells.get(cols[index].key), mode, side);
|
||||
const before = amountOf(row.cells.get(cols[index - 1].key), mode, side);
|
||||
if (!(before || now)) {
|
||||
return null;
|
||||
}
|
||||
const ratio = before ? (now - before) / Math.abs(before) : null;
|
||||
const up = now > before;
|
||||
const good = side === "out" ? !up : up;
|
||||
return { good, ratio, up };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-px mb-px overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th
|
||||
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
|
||||
>
|
||||
{nameLabel}
|
||||
</th>
|
||||
{#each cols as col (col.key)}
|
||||
<th
|
||||
class="h-7 whitespace-nowrap px-2 text-right font-medium"
|
||||
title={col.title}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
{/each}
|
||||
{#if average}
|
||||
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
|
||||
Average
|
||||
</th>
|
||||
{/if}
|
||||
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">Sum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as row (row.key)}
|
||||
{@const rowTotal = amountOf(row.totals, mode, side)}
|
||||
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
|
||||
<th
|
||||
class="sticky left-0 z-[1] max-w-[16rem] bg-cladd-surface px-3 text-left font-normal"
|
||||
scope="row"
|
||||
>
|
||||
<button
|
||||
aria-pressed={isActive(row, null)}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline {isActive(
|
||||
row,
|
||||
null
|
||||
)
|
||||
? "font-semibold text-brand dark:text-[var(--ff-series-1)]"
|
||||
: ""}"
|
||||
onclick={() => open(row, null)}
|
||||
title="Show transactions for {row.label}"
|
||||
type="button"
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
</th>
|
||||
{#each cols as col, i (col.key)}
|
||||
{@const value = amountOf(row.cells.get(col.key), mode, side)}
|
||||
{@const diff = delta ? change(row, i) : null}
|
||||
{@const cell = row.cells.get(col.key)}
|
||||
<td class="p-0 text-right">
|
||||
{#if multi && cell}
|
||||
<button
|
||||
class="w-full px-2 py-1 text-right hover:bg-row-cursor {isActive(
|
||||
row,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected font-semibold"
|
||||
: ""}"
|
||||
onclick={() => open(row, col)}
|
||||
tabindex="-1"
|
||||
title="{row.label}, {col.title ?? col.label}"
|
||||
type="button"
|
||||
>
|
||||
<Money codes={report.codes} {side} totals={cell} />
|
||||
</button>
|
||||
{:else if !multi && (value || diff)}
|
||||
<button
|
||||
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
|
||||
row,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected font-semibold"
|
||||
: ""}"
|
||||
onclick={() => open(row, col)}
|
||||
tabindex="-1"
|
||||
title="{row.label}, {col.title ?? col.label}: {money(
|
||||
mode,
|
||||
sign * value
|
||||
)}"
|
||||
type="button"
|
||||
>
|
||||
{#if diff}
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5 {diff.good
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>
|
||||
{#if diff.up}
|
||||
<ArrowUp aria-label="up" class="size-3" />
|
||||
{:else}
|
||||
<ArrowDown aria-label="down" class="size-3" />
|
||||
{/if}
|
||||
{diff.ratio === null
|
||||
? "new"
|
||||
: percent(Math.abs(diff.ratio))}
|
||||
</span>
|
||||
{:else}
|
||||
{short(sign * value)}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if average}
|
||||
<td
|
||||
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
|
||||
>
|
||||
{short((sign * rowTotal) / Math.max(1, cols.length))}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="num whitespace-nowrap px-3 text-right font-semibold">
|
||||
{#if multi}
|
||||
<Money codes={report.codes} {side} totals={row.totals} />
|
||||
{:else}
|
||||
{short(sign * rowTotal)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="h-7 border-cladd-outline border-t-2 font-semibold">
|
||||
<th
|
||||
class="sticky left-0 z-[1] bg-cladd-surface px-3 text-left"
|
||||
scope="row"
|
||||
>
|
||||
Sum
|
||||
</th>
|
||||
{#each cols as col, i (col.key)}
|
||||
<td class="p-0 text-right">
|
||||
{#if multi}
|
||||
<div class="px-2 py-1">
|
||||
<Money codes={report.codes} {side} totals={colMerged[i]} />
|
||||
</div>
|
||||
{:else if colTotals[i]}
|
||||
<button
|
||||
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
|
||||
null,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
onclick={() => open(null, col)}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
{short(sign * colTotals[i])}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if average}
|
||||
<td class="num whitespace-nowrap px-2 text-right">
|
||||
{short((sign * grand) / Math.max(1, cols.length))}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="num whitespace-nowrap px-3 text-right">
|
||||
{#if multi}
|
||||
<Money codes={report.codes} {side} totals={merge(colMerged)} />
|
||||
{:else}
|
||||
{short(sign * grand)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
amountOf,
|
||||
currencies,
|
||||
type Side,
|
||||
type Totals,
|
||||
} from "#lib/reports/flows.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
totals,
|
||||
side,
|
||||
codes,
|
||||
mode,
|
||||
}: {
|
||||
totals: Totals | undefined;
|
||||
side: Side;
|
||||
codes: string[];
|
||||
mode?: string;
|
||||
} = $props();
|
||||
|
||||
const lines = $derived.by(() => {
|
||||
if (!totals) {
|
||||
return [];
|
||||
}
|
||||
const list = mode === "pc" ? ["pc"] : currencies(totals, codes);
|
||||
return list
|
||||
.map((code) => ({ code, value: amountOf(totals, code, side) }))
|
||||
.filter((line) => Math.abs(line.value) > 0.000_001);
|
||||
});
|
||||
|
||||
function tone(value: number) {
|
||||
if (side === "out") {
|
||||
return "text-expense";
|
||||
}
|
||||
if (side === "in") {
|
||||
return "text-income";
|
||||
}
|
||||
return value < 0 ? "text-expense" : "text-income";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each lines as line (line.code)}
|
||||
<div class="num {tone(line.value)}">
|
||||
{money(line.code, side === "out" ? -line.value : line.value)}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { percent } from "#lib/format.js";
|
||||
import { type Flow, flowValue, keys } from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const LEFT_MAX = 5;
|
||||
const RIGHT_MAX = 9;
|
||||
const SLOT = 34;
|
||||
const BAR = 10;
|
||||
const CORE = 14;
|
||||
const CORE_HEIGHT = 260;
|
||||
const GAP = 4;
|
||||
const CENT = 0.01;
|
||||
|
||||
interface Node {
|
||||
color: string;
|
||||
dir: 1 | -1;
|
||||
drillable: boolean;
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
let width = $state(0);
|
||||
|
||||
function side(flows: Flow[], dir: 1 | -1, max: number): Node[] {
|
||||
const sums = new Map<string, { label: string; value: number }>();
|
||||
for (const flow of flows) {
|
||||
const value = flowValue(flow, mode);
|
||||
if (flow.dir !== dir || value === null) {
|
||||
continue;
|
||||
}
|
||||
const key = keys.category(flow);
|
||||
const entry = sums.get(key.key) ?? { label: key.label, value: 0 };
|
||||
entry.value += value;
|
||||
sums.set(key.key, entry);
|
||||
}
|
||||
const sorted = [...sums.entries()]
|
||||
.map(([key, entry]) => ({ key, ...entry }))
|
||||
.filter((entry) => entry.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const color = dir === 1 ? "var(--ff-income)" : "var(--ff-expense)";
|
||||
const head: Node[] = sorted.slice(0, max).map((entry) => ({
|
||||
color,
|
||||
dir,
|
||||
drillable: true,
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}));
|
||||
const rest = sorted.slice(max);
|
||||
if (rest.length > 0) {
|
||||
head.push({
|
||||
color: "var(--cladd-fg-softest)",
|
||||
dir,
|
||||
drillable: false,
|
||||
key: `other-${dir}`,
|
||||
label: `Other (${rest.length})`,
|
||||
value: rest.reduce((sum, entry) => sum + entry.value, 0),
|
||||
});
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
const net = $derived.by(() => {
|
||||
const sums = new Map<string, number>();
|
||||
for (const swap of report.exchanges) {
|
||||
if (swap.to === mode) {
|
||||
sums.set(swap.from, (sums.get(swap.from) ?? 0) + swap.toAmount);
|
||||
}
|
||||
if (swap.from === mode) {
|
||||
sums.set(swap.to, (sums.get(swap.to) ?? 0) - swap.fromAmount);
|
||||
}
|
||||
}
|
||||
return [...sums.entries()].filter(([, value]) => value !== 0);
|
||||
});
|
||||
|
||||
function swaps(dir: 1 | -1): Node[] {
|
||||
return net
|
||||
.filter(([, value]) => Math.sign(value) === dir)
|
||||
.map(([code, value]) => ({
|
||||
color: "var(--ff-transfer)",
|
||||
dir,
|
||||
drillable: false,
|
||||
key: `swap-${dir}-${code}`,
|
||||
label: dir === 1 ? `Exchanged from ${code}` : `Exchanged to ${code}`,
|
||||
value: Math.abs(value),
|
||||
}));
|
||||
}
|
||||
|
||||
const model = $derived.by(() => {
|
||||
const flows = report.flows ?? [];
|
||||
const left = side(flows, 1, LEFT_MAX);
|
||||
const right = side(flows, -1, RIGHT_MAX);
|
||||
if (mode !== "pc") {
|
||||
left.push(...swaps(1));
|
||||
right.push(...swaps(-1));
|
||||
}
|
||||
const totalIn = left.reduce((sum, n) => sum + n.value, 0);
|
||||
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
|
||||
if (totalIn - totalOut >= CENT) {
|
||||
right.push({
|
||||
color: "var(--ff-series-1)",
|
||||
dir: 1,
|
||||
drillable: false,
|
||||
key: "kept",
|
||||
label: "Left over",
|
||||
value: totalIn - totalOut,
|
||||
});
|
||||
} else if (totalOut - totalIn >= CENT) {
|
||||
left.push({
|
||||
color: "var(--ff-warn)",
|
||||
dir: -1,
|
||||
drillable: false,
|
||||
key: "deficit",
|
||||
label: "From balance",
|
||||
value: totalOut - totalIn,
|
||||
});
|
||||
}
|
||||
return { left, right, total: Math.max(totalIn, totalOut) };
|
||||
});
|
||||
|
||||
const narrow = $derived(width < 640);
|
||||
const labelWidth = $derived(Math.min(220, Math.max(110, width * 0.27)));
|
||||
const scale = $derived(model.total > 0 ? CORE_HEIGHT / model.total : 0);
|
||||
const slotOf = (n: Node) => Math.max(SLOT, n.value * scale + GAP * 2);
|
||||
const height = $derived(
|
||||
Math.max(
|
||||
CORE_HEIGHT + 20,
|
||||
model.left.reduce((s, n) => s + slotOf(n), 0),
|
||||
model.right.reduce((s, n) => s + slotOf(n), 0)
|
||||
)
|
||||
);
|
||||
const leftX = $derived(labelWidth);
|
||||
const rightX = $derived(width - labelWidth - BAR);
|
||||
const coreX = $derived((leftX + BAR + rightX) / 2 - CORE / 2);
|
||||
const coreTop = $derived((height - model.total * scale) / 2);
|
||||
|
||||
function layout(nodes: Node[]) {
|
||||
const used = nodes.reduce((s, n) => s + slotOf(n), 0);
|
||||
let y = (height - used) / 2;
|
||||
let core = coreTop;
|
||||
return nodes.map((node) => {
|
||||
const slot = slotOf(node);
|
||||
const h = Math.max(1.5, node.value * scale);
|
||||
const top = y + (slot - h) / 2;
|
||||
const placed = { center: y + slot / 2, core, h, node, top };
|
||||
y += slot;
|
||||
core += node.value * scale;
|
||||
return placed;
|
||||
});
|
||||
}
|
||||
|
||||
const left = $derived(layout(model.left));
|
||||
const right = $derived(layout(model.right));
|
||||
|
||||
function ribbon(x0: number, y0: number, x1: number, y1: number, h: number) {
|
||||
const mid = (x0 + x1) / 2;
|
||||
return `M${x0},${y0}C${mid},${y0} ${mid},${y1} ${x1},${y1}V${y1 + h}C${mid},${y1 + h} ${mid},${y0 + h} ${x0},${y0 + h}Z`;
|
||||
}
|
||||
|
||||
function open(node: Node) {
|
||||
if (!node.drillable) {
|
||||
return;
|
||||
}
|
||||
const { key } = node;
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === "flow" &&
|
||||
current.key === key &&
|
||||
current.dir === node.dir;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "flow",
|
||||
dim: "category",
|
||||
dir: node.dir,
|
||||
key,
|
||||
label: `${node.dir === 1 ? "Income" : "Expenses"}: ${node.label}`,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const share = (n: Node) =>
|
||||
model.total ? percent(n.value / model.total) : "";
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={model.total === 0}
|
||||
emptyText="No money moved in {modeLabel(mode)} in this period."
|
||||
id="flow"
|
||||
loading={!report.flows}
|
||||
title="Where the money went ({modeLabel(mode)})"
|
||||
>
|
||||
<div bind:clientWidth={width}>
|
||||
{#if width > 0 && narrow}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each [
|
||||
{ list: model.left, title: "In" },
|
||||
{ list: model.right, title: "Out" },
|
||||
] as column (column.title)}
|
||||
<div>
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{column.title}
|
||||
</h3>
|
||||
{#each column.list as node (node.key)}
|
||||
<button
|
||||
class="grid h-7 w-full grid-cols-[0.5rem_1fr_auto_2.5rem] items-center gap-2 rounded-cladd-sm px-1 text-left hover:bg-row-hover disabled:hover:bg-transparent"
|
||||
disabled={!node.drillable}
|
||||
onclick={() => open(node)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
style:background={node.color}
|
||||
></span>
|
||||
<span class="truncate">{node.label}</span>
|
||||
<span class="num">{money(mode, node.value)}</span>
|
||||
<span class="num text-right text-cladd-fg-soft"
|
||||
>{share(node)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if width > 0}
|
||||
<div class="relative" style:height="{height}px">
|
||||
<svg aria-hidden="true" class="absolute inset-0" {height} {width}>
|
||||
{#each left as item (item.node.key)}
|
||||
<path
|
||||
d={ribbon(leftX + BAR, item.top, coreX, item.core, item.h)}
|
||||
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
|
||||
/>
|
||||
<rect
|
||||
fill={item.node.color}
|
||||
height={item.h}
|
||||
rx="2"
|
||||
width={BAR}
|
||||
x={leftX}
|
||||
y={item.top}
|
||||
/>
|
||||
{/each}
|
||||
{#each right as item (item.node.key)}
|
||||
<path
|
||||
d={ribbon(coreX + CORE, item.core, rightX, item.top, item.h)}
|
||||
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
|
||||
/>
|
||||
<rect
|
||||
fill={item.node.color}
|
||||
height={item.h}
|
||||
rx="2"
|
||||
width={BAR}
|
||||
x={rightX}
|
||||
y={item.top}
|
||||
/>
|
||||
{/each}
|
||||
<rect
|
||||
fill="var(--cladd-fg-soft)"
|
||||
height={model.total * scale}
|
||||
rx="3"
|
||||
width={CORE}
|
||||
x={coreX}
|
||||
y={coreTop}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="num absolute -translate-x-1/2 font-semibold text-[12px]"
|
||||
style:left="{coreX + CORE / 2}px"
|
||||
style:top="{coreTop - 22}px"
|
||||
>{money(mode, model.total)}</span
|
||||
>
|
||||
{#each [
|
||||
...left.map((item) => ({ ...item, isLeft: true })),
|
||||
...right.map((item) => ({ ...item, isLeft: false })),
|
||||
] as item (`${item.isLeft}-${item.node.key}`)}
|
||||
{@const active =
|
||||
report.drill?.box === "flow" &&
|
||||
report.drill.key === item.node.key &&
|
||||
report.drill.dir === item.node.dir}
|
||||
<button
|
||||
class="absolute flex h-8 flex-col justify-center rounded-cladd-sm px-1 leading-tight hover:bg-row-hover disabled:hover:bg-transparent {item.isLeft
|
||||
? "items-end text-right"
|
||||
: "items-start text-left"} {active ? "bg-row-selected" : ""}"
|
||||
disabled={!item.node.drillable}
|
||||
onclick={() => open(item.node)}
|
||||
title={item.node.drillable
|
||||
? `Show transactions for ${item.node.label}`
|
||||
: undefined}
|
||||
type="button"
|
||||
style:left="{item.isLeft ? 0 : rightX + BAR + 4}px"
|
||||
style:top="{item.center - 16}px"
|
||||
style:width="{labelWidth - 6}px"
|
||||
>
|
||||
<span
|
||||
class="max-w-full truncate text-[12px] {active
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
>{item.node.label}</span
|
||||
>
|
||||
<span class="num text-[11px] text-cladd-fg-soft"
|
||||
>{money(mode, item.node.value)}
|
||||
· {share(item.node)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import { Popover } from "bits-ui";
|
||||
import type { Option } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
options,
|
||||
value,
|
||||
onchange,
|
||||
label,
|
||||
id,
|
||||
open = $bindable(false),
|
||||
loading = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
options: Option[];
|
||||
value: string[];
|
||||
onchange: (next: string[]) => void;
|
||||
label: string;
|
||||
id: string;
|
||||
open?: boolean;
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state("");
|
||||
let active = $state(0);
|
||||
|
||||
const chosen = $derived(new Set(value));
|
||||
const visible = $derived(
|
||||
query
|
||||
? options.filter((o) =>
|
||||
`${o.label} ${o.group ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
)
|
||||
: options
|
||||
);
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, Option[]>();
|
||||
for (const option of visible) {
|
||||
const key = option.group ?? "";
|
||||
map.set(key, [...(map.get(key) ?? []), option]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
});
|
||||
const summary = $derived.by(() => {
|
||||
const picked = options.filter((o) => chosen.has(o.id));
|
||||
if (picked.length === 0) {
|
||||
return loading ? "Loading" : "None selected";
|
||||
}
|
||||
if (picked.length === options.length && options.length > 2) {
|
||||
return `All selected (${options.length})`;
|
||||
}
|
||||
if (picked.length <= 2) {
|
||||
return picked.map((o) => o.label).join(", ");
|
||||
}
|
||||
return `${picked.length} selected: ${picked.map((o) => o.label).join(", ")}`;
|
||||
});
|
||||
|
||||
function toggle(ids: string[], on: boolean) {
|
||||
const next = new Set(chosen);
|
||||
for (const optionId of ids) {
|
||||
if (on) {
|
||||
next.add(optionId);
|
||||
} else {
|
||||
next.delete(optionId);
|
||||
}
|
||||
}
|
||||
onchange(options.filter((o) => next.has(o.id)).map((o) => o.id));
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = Math.max(0, Math.min(visible.length - 1, active + step));
|
||||
document
|
||||
.getElementById(`${id}-opt-${active}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
if (event.key === "Enter" && !(event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
const option = visible[active];
|
||||
if (option) {
|
||||
toggle([option.id], !chosen.has(option.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
query = "";
|
||||
active = 0;
|
||||
}
|
||||
}}
|
||||
bind:open
|
||||
>
|
||||
<Popover.Trigger
|
||||
aria-label="{label}: {summary}"
|
||||
class="cladd-cut cladd-focusable-input flex h-8 min-w-0 items-center gap-2 rounded-cladd-sm px-2.5 text-left text-[13px] text-cladd-fg {className}"
|
||||
{id}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{summary}</span>
|
||||
<ChevronDown class="size-3.5 shrink-0 text-cladd-fg-soft" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 flex max-h-[min(70vh,32rem)] w-80 flex-col rounded-cladd-lg text-cladd-fg shadow-cladd-popover outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex flex-col gap-2 border-cladd-outline border-b p-2">
|
||||
<input
|
||||
aria-label="Find {label.toLowerCase()}"
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px]"
|
||||
{onkeydown}
|
||||
placeholder="Find.."
|
||||
bind:value={query}
|
||||
>
|
||||
<div class="flex items-center gap-3 text-[12px]">
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
visible.map((o) => o.id),
|
||||
true
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
visible.map((o) => o.id),
|
||||
false
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<span class="num ml-auto text-cladd-fg-soft"
|
||||
>{chosen.size}
|
||||
of {options.length}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{#each groups as [group, items] (group)}
|
||||
{#if group}
|
||||
{@const all = items.every((o) => chosen.has(o.id))}
|
||||
<button
|
||||
class="flex h-7 w-full items-center px-2 text-left font-semibold text-[11px] text-cladd-fg-soft uppercase tracking-wide hover:text-cladd-fg"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
items.map((o) => o.id),
|
||||
!all
|
||||
)}
|
||||
title={all ? `Deselect ${group}` : `Select ${group}`}
|
||||
type="button"
|
||||
>
|
||||
{group}
|
||||
</button>
|
||||
{/if}
|
||||
{#each items as option (option.id)}
|
||||
{@const index = visible.indexOf(option)}
|
||||
<label
|
||||
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 text-[13px] hover:bg-row-hover {index ===
|
||||
active
|
||||
? "bg-row-cursor"
|
||||
: ""}"
|
||||
id="{id}-opt-{index}"
|
||||
>
|
||||
<input
|
||||
checked={chosen.has(option.id)}
|
||||
class="size-3.5 accent-[var(--ff-brand)]"
|
||||
onchange={(event) =>
|
||||
toggle([option.id], event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{#if option.note}
|
||||
<span class="shrink-0 text-[11px] text-cladd-fg-soft"
|
||||
>{option.note}</span
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="px-2 py-1.5 text-cladd-fg-soft">
|
||||
{#if query}
|
||||
Nothing matches
|
||||
{:else}
|
||||
{loading ? "Loading" : "Nothing to choose from"}
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { recall, remember } from "#lib/reports/memory.js";
|
||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
||||
import {
|
||||
LIST_LABELS,
|
||||
REPORT_KINDS,
|
||||
type ReportParams,
|
||||
type ReportType,
|
||||
rangePresets,
|
||||
shiftRange,
|
||||
} from "#lib/reports/params.js";
|
||||
import {
|
||||
accountOptions,
|
||||
modeLabel,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import MultiSelect from "./MultiSelect.svelte";
|
||||
|
||||
const report = useReport();
|
||||
|
||||
let accountsOpen = $state(false);
|
||||
let itemsOpen = $state(false);
|
||||
let typeSelect = $state<HTMLSelectElement>();
|
||||
let startInput = $state<HTMLInputElement>();
|
||||
|
||||
const p = $derived(report.params);
|
||||
const presets = rangePresets();
|
||||
const preset = $derived(
|
||||
presets.find((x) => x.range.start === p.start && x.range.end === p.end)
|
||||
?.key ?? "custom"
|
||||
);
|
||||
const options = $derived(accountOptions(report.accounts()));
|
||||
const list = $derived(report.kind?.list);
|
||||
const sources = $derived({
|
||||
budgets: report.budgets,
|
||||
categories: report.categories,
|
||||
expense: report.expense,
|
||||
revenue: report.revenue,
|
||||
tags: report.tags,
|
||||
});
|
||||
const itemOptions = $derived(list ? listOptions(list, sources) : []);
|
||||
const accountValue = $derived(report.selected);
|
||||
|
||||
function go(changes: Partial<ReportParams>) {
|
||||
const next = { ...p, ...changes };
|
||||
remember(next);
|
||||
navigate({}, report.path(changes));
|
||||
}
|
||||
|
||||
function defaultItems(type: ReportType): string[] {
|
||||
const kind = REPORT_KINDS.find((k) => k.type === type)?.list;
|
||||
if (!kind) {
|
||||
return [];
|
||||
}
|
||||
const stored = recall().items[kind];
|
||||
if (stored && stored.length > 0) {
|
||||
return stored;
|
||||
}
|
||||
if (kind === "double") {
|
||||
const top = new Map<string, number>();
|
||||
for (const flow of report.flows ?? []) {
|
||||
if (flow.type === "withdrawal") {
|
||||
top.set(flow.other.id, (top.get(flow.other.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const ids = [...top.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([id]) => id);
|
||||
return ids.length > 0 ? ids : ["0"];
|
||||
}
|
||||
return [
|
||||
{ budget: "allBudgets", category: "allCategories", tag: "allTags" }[kind],
|
||||
];
|
||||
}
|
||||
|
||||
function setType(type: ReportType) {
|
||||
go({ items: defaultItems(type), type });
|
||||
}
|
||||
|
||||
function setRange(start: string, end: string) {
|
||||
if (start && end && start <= end) {
|
||||
go({ end, start });
|
||||
}
|
||||
}
|
||||
|
||||
function shift(dir: 1 | -1) {
|
||||
const range = shiftRange(p, dir);
|
||||
go({ end: range.end, start: range.start });
|
||||
}
|
||||
|
||||
function cycleMode() {
|
||||
const { available } = report;
|
||||
if (available.length > 1) {
|
||||
const next =
|
||||
available[(available.indexOf(report.mode) + 1) % available.length];
|
||||
navigate({ cur: next });
|
||||
}
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "[",
|
||||
label: "Shift report back one period",
|
||||
run: () => shift(-1),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "]",
|
||||
label: "Shift report forward one period",
|
||||
run: () => shift(1),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "t",
|
||||
label: "Change report type",
|
||||
run: () => typeSelect?.focus(),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "a",
|
||||
label: "Choose accounts",
|
||||
run: () => (accountsOpen = true),
|
||||
},
|
||||
...(list
|
||||
? [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "f",
|
||||
label: `Choose ${LIST_LABELS[list].many.toLowerCase()}`,
|
||||
run: () => (itemsOpen = true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
group: "Report",
|
||||
keys: "d",
|
||||
label: "Change dates",
|
||||
run: () => startInput?.focus(),
|
||||
},
|
||||
...(report.available.length > 1
|
||||
? [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "c",
|
||||
label: "Next currency in charts",
|
||||
run: cycleMode,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="cladd-surface cladd-outline z-20 mb-4 lg:sticky lg:top-14 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-cladd-lg px-3 py-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[11px] text-cladd-fg-soft">Report type</span>
|
||||
<select
|
||||
class="cladd-cut cladd-focusable-input h-8 max-w-[14rem] rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={(event) => setType(event.currentTarget.value as ReportType)}
|
||||
value={p.type}
|
||||
bind:this={typeSelect}
|
||||
>
|
||||
{#each REPORT_KINDS as kind (kind.type)}
|
||||
<option value={kind.type}>{kind.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label class="text-[11px] text-cladd-fg-soft" for="report-accounts"
|
||||
>Accounts</label
|
||||
>
|
||||
<MultiSelect
|
||||
class="w-48"
|
||||
id="report-accounts"
|
||||
label="Accounts"
|
||||
loading={!report.accountsReady}
|
||||
onchange={(accounts) => go({ accounts })}
|
||||
{options}
|
||||
value={accountValue}
|
||||
bind:open={accountsOpen}
|
||||
/>
|
||||
</div>
|
||||
{#if list}
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label class="text-[11px] text-cladd-fg-soft" for="report-items"
|
||||
>{LIST_LABELS[list].many}</label
|
||||
>
|
||||
<MultiSelect
|
||||
class="w-48"
|
||||
id="report-items"
|
||||
label={LIST_LABELS[list].many}
|
||||
loading={!listReady(list, sources)}
|
||||
onchange={(items) => go({ items })}
|
||||
options={itemOptions}
|
||||
value={report.items}
|
||||
bind:open={itemsOpen}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[11px] text-cladd-fg-soft">Date range</span>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
aria-label="Previous period"
|
||||
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={() => shift(-1)}
|
||||
title="Previous period ["
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
<input
|
||||
aria-label="Start date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
|
||||
onchange={(event) => setRange(event.currentTarget.value, p.end)}
|
||||
type="date"
|
||||
value={p.start}
|
||||
bind:this={startInput}
|
||||
>
|
||||
<span class="text-cladd-fg-soft">to</span>
|
||||
<input
|
||||
aria-label="End date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
|
||||
onchange={(event) => setRange(p.start, event.currentTarget.value)}
|
||||
type="date"
|
||||
value={p.end}
|
||||
>
|
||||
<button
|
||||
aria-label="Next period"
|
||||
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={() => shift(1)}
|
||||
title="Next period ]"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Preset range"
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={(event) => {
|
||||
const found = presets.find(
|
||||
(x) => x.key === event.currentTarget.value
|
||||
);
|
||||
if (found) {
|
||||
setRange(found.range.start, found.range.end);
|
||||
}
|
||||
}}
|
||||
value={preset}
|
||||
>
|
||||
{#each presets as item (item.key)}
|
||||
<option value={item.key}>{item.label}</option>
|
||||
{/each}
|
||||
<option disabled value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if report.available.length > 1}
|
||||
<fieldset class="shrink-0">
|
||||
<legend class="float-left mb-0.5 w-full text-[11px] text-cladd-fg-soft">
|
||||
Charts in
|
||||
</legend>
|
||||
<div
|
||||
class="cladd-cut clear-both flex h-8 items-center gap-0.5 rounded-cladd-sm p-0.5"
|
||||
>
|
||||
{#each report.available as mode (mode)}
|
||||
<button
|
||||
aria-pressed={report.mode === mode}
|
||||
class="num h-7 rounded-[5px] px-2 text-[12px] {report.mode === mode
|
||||
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => navigate({ cur: mode })}
|
||||
title="Show charts in {modeLabel(mode)} c"
|
||||
type="button"
|
||||
>
|
||||
{modeLabel(mode)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Columns from "#lib/components/charts/Columns.svelte";
|
||||
import { buckets, type Dimension, type Flow } from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import {
|
||||
bucketLabel,
|
||||
bucketRows,
|
||||
FLOW_KEYS,
|
||||
modeAxis,
|
||||
modeFormat,
|
||||
upright,
|
||||
} from "#lib/reports/view.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let {
|
||||
id = "period-chart",
|
||||
title = "Income vs. expenses",
|
||||
flows,
|
||||
dim,
|
||||
itemKey,
|
||||
}: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
flows: Flow[] | undefined;
|
||||
dim?: Dimension;
|
||||
itemKey?: string;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const keys = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain)
|
||||
);
|
||||
const rows = $derived(
|
||||
flows ? upright(bucketRows(flows, keys, report.grain, report.mode)) : []
|
||||
);
|
||||
const shownKeys = $derived(
|
||||
FLOW_KEYS.filter((key) =>
|
||||
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
|
||||
)
|
||||
);
|
||||
const active = $derived(
|
||||
report.drill?.box === id ? (report.drill.at ?? null) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={shownKeys.length === 0}
|
||||
{id}
|
||||
loading={!flows}
|
||||
title={title.endsWith(",")
|
||||
? `${title} ${modeLabel(report.mode)}`
|
||||
: `${title} (${modeLabel(report.mode)})`}
|
||||
>
|
||||
<Columns
|
||||
{active}
|
||||
axisFormat={modeAxis(report.mode)}
|
||||
format={modeFormat(report.mode)}
|
||||
height={260}
|
||||
keys={shownKeys}
|
||||
label="{title} per period"
|
||||
onselect={(row) =>
|
||||
navigate(
|
||||
drillQuery(
|
||||
active === row.key
|
||||
? null
|
||||
: {
|
||||
at: row.key,
|
||||
box: id,
|
||||
dim: itemKey === undefined ? undefined : dim,
|
||||
key: itemKey,
|
||||
label: [
|
||||
itemKey === undefined ? "" : title,
|
||||
bucketLabel(row.key, report.grain),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
}
|
||||
)
|
||||
)}
|
||||
{rows}
|
||||
/>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from "svelte";
|
||||
import type { SvelteSet } from "svelte/reactivity";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { useReport } from "#lib/reports/report.svelte.js";
|
||||
import DrillPanel from "./DrillPanel.svelte";
|
||||
import { ROW } from "./ReportRow.svelte";
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
loading = false,
|
||||
empty = false,
|
||||
emptyText = "Nothing in this period.",
|
||||
tools,
|
||||
footer,
|
||||
children,
|
||||
body = "p-0",
|
||||
class: className = "",
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
empty?: boolean;
|
||||
emptyText?: string;
|
||||
tools?: Snippet;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
body?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const row = getContext<SvelteSet<string> | undefined>(ROW);
|
||||
$effect(() => {
|
||||
row?.add(id);
|
||||
return () => row?.delete(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Box {body} class={className} {footer} {title} {tools}>
|
||||
<div class="scroll-mt-24" {id}>
|
||||
{#if loading}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||
{:else if empty}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">{emptyText}</p>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if !row && report.drill?.box === id && !loading}
|
||||
<DrillPanel drill={report.drill} />
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { formatRange } from "#lib/format.js";
|
||||
import type { ReportParams } from "#lib/reports/params.js";
|
||||
import { createReport, provideReport } from "#lib/reports/report.svelte.js";
|
||||
import AuditReport from "./AuditReport.svelte";
|
||||
import DefaultReport from "./DefaultReport.svelte";
|
||||
import DimensionReport from "./DimensionReport.svelte";
|
||||
import ParamBar from "./ParamBar.svelte";
|
||||
|
||||
let { params }: { params: ReportParams } = $props();
|
||||
|
||||
const report = createReport(() => params);
|
||||
provideReport(report);
|
||||
|
||||
const SUBTITLES: Record<string, string> = {
|
||||
audit: "Transaction history account overview",
|
||||
budget: "Budget report",
|
||||
category: "Category report",
|
||||
default: "Default financial report",
|
||||
double: "Expense/revenue account report",
|
||||
tag: "Tag report",
|
||||
};
|
||||
|
||||
const subtitle = $derived(
|
||||
`${SUBTITLES[params.type]}, ${formatRange(params.start, params.end)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
crumbs={[
|
||||
{ href: "/reports", label: "Reports" },
|
||||
{ label: report.kind?.label ?? "" },
|
||||
]}
|
||||
{subtitle}
|
||||
title="Reports"
|
||||
/>
|
||||
<ParamBar />
|
||||
{#if report.error}
|
||||
<p class="mb-4 text-expense">
|
||||
Firefly III did not return the data for this report: {report.error.message}
|
||||
</p>
|
||||
{/if}
|
||||
{#if params.type === "default"}
|
||||
<DefaultReport />
|
||||
{:else if params.type === "audit"}
|
||||
<AuditReport />
|
||||
{:else}
|
||||
{#key params.type}
|
||||
<DimensionReport />
|
||||
{/key}
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" module>
|
||||
export const ROW = Symbol("report-row");
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet, setContext } from "svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { useReport } from "#lib/reports/report.svelte.js";
|
||||
import DrillPanel from "./DrillPanel.svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = "",
|
||||
}: { children: Snippet; class?: string } = $props();
|
||||
|
||||
const boxes = new SvelteSet<string>();
|
||||
setContext(ROW, boxes);
|
||||
|
||||
const report = useReport();
|
||||
const drill = $derived(
|
||||
report.drill && boxes.has(report.drill.box) ? report.drill : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 {className}">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if drill}
|
||||
<div
|
||||
class="cladd-surface cladd-outline scroll-mt-24 overflow-hidden rounded-cladd-lg"
|
||||
id="drill-{drill.box}"
|
||||
>
|
||||
<DrillPanel {drill} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { percent } from "#lib/format.js";
|
||||
import {
|
||||
amountOf,
|
||||
type Dimension,
|
||||
type Row,
|
||||
rank,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
side,
|
||||
dim,
|
||||
limit = 8,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
side: "in" | "out";
|
||||
dim: Dimension;
|
||||
limit?: number;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
const dir = $derived(side === "in" ? 1 : -1);
|
||||
|
||||
const ranked = $derived(
|
||||
rank(rows, mode, side)
|
||||
.map((row) => ({ row, value: amountOf(row.totals, mode, side) }))
|
||||
.filter((item) => item.value > 0)
|
||||
);
|
||||
const head = $derived(ranked.slice(0, limit));
|
||||
const rest = $derived(ranked.slice(limit));
|
||||
const restValue = $derived(rest.reduce((acc, item) => acc + item.value, 0));
|
||||
const sum = $derived(ranked.reduce((acc, item) => acc + item.value, 0));
|
||||
const max = $derived(
|
||||
Math.max(restValue, ...head.map((item) => item.value), 1)
|
||||
);
|
||||
const color = $derived(
|
||||
side === "in" ? "var(--ff-income)" : "var(--ff-expense)"
|
||||
);
|
||||
|
||||
function open(row: Row) {
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === box && current.key === row.key && current.dim === dim;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same ? null : { box, dim, dir, key: row.key, label: row.label }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet line(
|
||||
label: string,
|
||||
value: number,
|
||||
fill: string
|
||||
)}
|
||||
<span class="flex w-full items-baseline gap-3">
|
||||
<span class="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span class="num">{money(mode, value)}</span>
|
||||
<span class="num w-9 text-right text-cladd-fg-soft"
|
||||
>{percent(value / sum)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="block h-1.5 w-full rounded-full bg-cladd-surface-next">
|
||||
<span
|
||||
class="block h-full rounded-full"
|
||||
style:background={fill}
|
||||
style:width="{(value / max) * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if ranked.length === 0}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">
|
||||
Nothing in {mode === "pc" ? "this period" : mode}.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col py-1.5">
|
||||
{#each head as item (item.row.key)}
|
||||
{@const active =
|
||||
report.drill?.box === box && report.drill.key === item.row.key}
|
||||
<li>
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="flex w-full flex-col gap-1 px-3 py-1 text-left hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
onclick={() => open(item.row)}
|
||||
title="Show transactions for {item.row.label}"
|
||||
type="button"
|
||||
>
|
||||
{@render line(item.row.label, item.value, color)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if rest.length > 0}
|
||||
<li class="flex flex-col gap-1 px-3 py-1 text-cladd-fg-soft">
|
||||
{@render line(
|
||||
`Other (${rest.length})`,
|
||||
restValue,
|
||||
"var(--cladd-fg-softest)"
|
||||
)}
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { addDays, daysBetween, eachDay, parse } from "#lib/dates.js";
|
||||
import { compactMoney, formatDate, formatMoney } from "#lib/format.js";
|
||||
import { daily } from "#lib/reports/flows.js";
|
||||
import {
|
||||
currencyMeta,
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const STEPS = [14, 30, 48, 68, 88];
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const BIG_CELL_DAYS = 62;
|
||||
const TOP_DAYS = 5;
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const mode = $derived(report.mode);
|
||||
let width = $state(0);
|
||||
let cursor = $state<string | null>(null);
|
||||
|
||||
const spending = $derived(
|
||||
report.flows ? daily(report.flows, mode, -1) : null
|
||||
);
|
||||
const days = $derived(eachDay(p));
|
||||
const big = $derived(days.length <= BIG_CELL_DAYS);
|
||||
const values = $derived(
|
||||
[...(spending?.values() ?? [])].map((d) => d.value).sort((a, b) => a - b)
|
||||
);
|
||||
const thresholds = $derived(
|
||||
STEPS.slice(1).map(
|
||||
(_, i) =>
|
||||
values[Math.floor(((i + 1) / STEPS.length) * values.length)] ?? 0
|
||||
)
|
||||
);
|
||||
const top = $derived(
|
||||
[...(spending?.entries() ?? [])]
|
||||
.sort((a, b) => b[1].value - a[1].value)
|
||||
.slice(0, TOP_DAYS)
|
||||
);
|
||||
const quiet = $derived(
|
||||
days.filter((d) => !spending?.has(d) && d <= p.end).length
|
||||
);
|
||||
const meta = $derived(
|
||||
mode === "pc"
|
||||
? {
|
||||
decimals: 0,
|
||||
symbol: session.primary?.symbol ?? "",
|
||||
}
|
||||
: { decimals: 0, symbol: currencyMeta(mode).symbol }
|
||||
);
|
||||
|
||||
function level(value: number) {
|
||||
let index = 0;
|
||||
while (index < thresholds.length && value > thresholds[index]) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const fill = (step: number) =>
|
||||
`color-mix(in oklch, var(--ff-expense) ${STEPS[step]}%, var(--cladd-surface))`;
|
||||
|
||||
const monthShort = $derived(
|
||||
new Intl.DateTimeFormat(session.locale, { month: "short" })
|
||||
);
|
||||
|
||||
const weekday = (day: string) => (parse(day).getDay() + 6) % 7;
|
||||
|
||||
const months = $derived.by(() => {
|
||||
const list: { key: string; label: string; cells: (string | null)[] }[] = [];
|
||||
for (const day of days) {
|
||||
const key = day.slice(0, 7);
|
||||
let month = list.at(-1);
|
||||
if (month?.key !== key) {
|
||||
month = {
|
||||
cells: Array.from({ length: weekday(day) }, () => null),
|
||||
key,
|
||||
label: formatDate(`${key}-01`, "month"),
|
||||
};
|
||||
list.push(month);
|
||||
}
|
||||
month.cells.push(day);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const weeks = $derived.by(() => {
|
||||
if (big) {
|
||||
return [];
|
||||
}
|
||||
const first = addDays(p.start, -weekday(p.start));
|
||||
const count = Math.ceil((daysBetween(first, p.end) + 1) / 7);
|
||||
return Array.from({ length: count }, (_, w) =>
|
||||
Array.from({ length: 7 }, (__, d) => {
|
||||
const day = addDays(first, w * 7 + d);
|
||||
return day < p.start || day > p.end ? null : day;
|
||||
})
|
||||
);
|
||||
});
|
||||
const cellSize = $derived(
|
||||
big
|
||||
? 0
|
||||
: Math.max(
|
||||
8,
|
||||
Math.min(16, Math.floor((width - 40) / Math.max(1, weeks.length)) - 2)
|
||||
)
|
||||
);
|
||||
|
||||
function amount(value: number, compact: boolean) {
|
||||
return compact
|
||||
? compactMoney(value, meta.symbol)
|
||||
: formatMoney({ amount: value, ...meta });
|
||||
}
|
||||
|
||||
function open(day: string) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "calendar" && current.key === day;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "calendar",
|
||||
dim: "day",
|
||||
dir: -1,
|
||||
key: day,
|
||||
label: formatDate(day, "long"),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const focusDay = $derived(
|
||||
cursor ?? days.find((d) => spending?.has(d)) ?? p.start
|
||||
);
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const moves: Record<string, number> = {
|
||||
ArrowDown: big ? 7 : 1,
|
||||
ArrowLeft: big ? -1 : -7,
|
||||
ArrowRight: big ? 1 : 7,
|
||||
ArrowUp: big ? -7 : -1,
|
||||
};
|
||||
if (event.key in moves) {
|
||||
event.preventDefault();
|
||||
const next = addDays(focusDay, moves[event.key]);
|
||||
if (next >= p.start && next <= p.end) {
|
||||
cursor = next;
|
||||
document.getElementById(`cal-${next}`)?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = (day: string) => {
|
||||
const value = spending?.get(day)?.value ?? 0;
|
||||
return `${formatDate(day, "long")}: ${value ? money(mode, -value) : "no spending"}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={values.length === 0}
|
||||
emptyText="No spending in {modeLabel(mode)} in this period."
|
||||
id="calendar"
|
||||
loading={!spending}
|
||||
title="Daily spending ({modeLabel(mode)})"
|
||||
>
|
||||
<div bind:clientWidth={width}>
|
||||
{#if big}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each months as month (month.key)}
|
||||
<div>
|
||||
{#if months.length > 1}
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{month.label}
|
||||
</h3>
|
||||
{/if}
|
||||
<div class="grid grid-cols-7 gap-0.5">
|
||||
{#each WEEKDAYS as name (name)}
|
||||
<div class="px-1 text-[11px] text-cladd-fg-soft">{name}</div>
|
||||
{/each}
|
||||
{#each month.cells as day, i (day ?? `pad-${i}`)}
|
||||
{#if day}
|
||||
{@const value = spending?.get(day)?.value ?? 0}
|
||||
{@const step = value ? level(value) : -1}
|
||||
<button
|
||||
aria-label={label(day)}
|
||||
class="flex h-11 min-w-0 flex-col justify-between rounded-[4px] px-1.5 py-1 text-left {report
|
||||
.drill?.box === "calendar" && report.drill.key === day
|
||||
? "outline-2 outline-cladd-fg"
|
||||
: ""}"
|
||||
id="cal-{day}"
|
||||
onclick={() => open(day)}
|
||||
onfocus={() => (cursor = day)}
|
||||
{onkeydown}
|
||||
tabindex={day === focusDay ? 0 : -1}
|
||||
type="button"
|
||||
style:background={step >= 0
|
||||
? fill(step)
|
||||
: "var(--cladd-surface-next)"}
|
||||
style:color={step >= 3 ? "white" : undefined}
|
||||
>
|
||||
<span class="num text-[11px] opacity-70"
|
||||
>{Number(day.slice(8, 10))}</span
|
||||
>
|
||||
{#if value}
|
||||
<span
|
||||
class="num self-end truncate text-[12px] font-medium"
|
||||
>{amount(value, width < 520)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-start gap-x-8 gap-y-4">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-[2px]">
|
||||
<div class="mr-1 flex flex-col gap-[2px] pt-4">
|
||||
{#each WEEKDAYS as name, i (name)}
|
||||
<div
|
||||
class="text-[10px] leading-none text-cladd-fg-soft"
|
||||
style:height="{cellSize}px"
|
||||
>
|
||||
{i % 2 === 0 ? name : ""}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each weeks as week, w (w)}
|
||||
{@const firstDay = week.find((d) => d !== null)}
|
||||
<div class="flex flex-col gap-[2px]">
|
||||
<div
|
||||
class="h-4 whitespace-nowrap text-[10px] text-cladd-fg-soft"
|
||||
style:width="{cellSize}px"
|
||||
>
|
||||
{#if firstDay &&
|
||||
(w === 0 || Number(firstDay.slice(8, 10)) <= 7)}
|
||||
{monthShort.format(parse(firstDay))}
|
||||
{/if}
|
||||
</div>
|
||||
{#each week as day, d (d)}
|
||||
{#if day}
|
||||
{@const value = spending?.get(day)?.value ?? 0}
|
||||
{@const step = value ? level(value) : -1}
|
||||
<button
|
||||
aria-label={label(day)}
|
||||
class="rounded-[3px]"
|
||||
id="cal-{day}"
|
||||
onclick={() => open(day)}
|
||||
onfocus={() => (cursor = day)}
|
||||
{onkeydown}
|
||||
tabindex={day === focusDay ? 0 : -1}
|
||||
title={label(day)}
|
||||
type="button"
|
||||
style:background={step >= 0
|
||||
? fill(step)
|
||||
: "var(--cladd-surface-next)"}
|
||||
style:height="{cellSize}px"
|
||||
style:width="{cellSize}px"
|
||||
></button>
|
||||
{:else}
|
||||
<div
|
||||
style:height="{cellSize}px"
|
||||
style:width="{cellSize}px"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-[14rem] flex-1">
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
Biggest days
|
||||
</h3>
|
||||
<ul>
|
||||
{#each top as [day, info] (day)}
|
||||
<li>
|
||||
<button
|
||||
class="flex h-7 w-full items-center justify-between gap-3 rounded-cladd-sm px-1 hover:bg-row-hover"
|
||||
onclick={() => open(day)}
|
||||
type="button"
|
||||
>
|
||||
<span class="num text-cladd-fg-soft"
|
||||
>{formatDate(day, "medium")}</span
|
||||
>
|
||||
<span class="text-[11px] text-cladd-fg-soft"
|
||||
>{info.n}
|
||||
{info.n === 1 ? "transaction" : "transactions"}</span
|
||||
>
|
||||
<span class="num ml-auto">{money(mode, -info.value)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2 text-[12px] text-cladd-fg-soft">
|
||||
{quiet}
|
||||
of {days.length} days without spending
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-3 flex flex-wrap items-center gap-1.5 text-[11px] text-cladd-fg-soft"
|
||||
>
|
||||
<span>Less</span>
|
||||
{#each STEPS as _, i (i)}
|
||||
<span
|
||||
class="inline-block size-3 rounded-[3px]"
|
||||
title={i === 0
|
||||
? `up to ${amount(thresholds[0] ?? 0, false)}`
|
||||
: `over ${amount(thresholds[i - 1] ?? 0, false)}`}
|
||||
style:background={fill(i)}
|
||||
></span>
|
||||
{/each}
|
||||
<span>More</span>
|
||||
{#if thresholds.length > 0}
|
||||
<span class="num ml-2"
|
||||
>over {amount(thresholds.at(-1) ?? 0, false)} is the darkest</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { contains } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { expectedDates } from "#lib/reports/bills.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const bills = createQuery(() => reportQueries.bills(p.start, p.end));
|
||||
|
||||
const rows = $derived(
|
||||
(bills.data ?? [])
|
||||
.map((item) => {
|
||||
const a = item.attributes;
|
||||
const expected = expectedDates(a, p);
|
||||
const paid = (a.paid_dates ?? []).filter(
|
||||
(d) => d.date && contains(p, d.date)
|
||||
);
|
||||
return { a, expected, id: item.id, paid };
|
||||
})
|
||||
.filter(
|
||||
(row) =>
|
||||
row.a.active && (row.expected.length > 0 || row.paid.length > 0)
|
||||
)
|
||||
.sort((x, y) => (x.a.name ?? "").localeCompare(y.a.name ?? ""))
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
empty={rows.length === 0}
|
||||
emptyText="No subscriptions expected or paid in this period."
|
||||
id="bills"
|
||||
loading={!bills.data}
|
||||
title="Subscriptions"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Name</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Minimum amount</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Maximum amount</th>
|
||||
<th class="h-7 px-3 text-left font-medium">Expected on</th>
|
||||
<th class="h-7 px-3 text-left font-medium">Paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
{@const code = row.a.currency_code ?? ""}
|
||||
<tr
|
||||
class="border-cladd-outline border-t align-top hover:bg-row-hover"
|
||||
>
|
||||
<td class="max-w-[16rem] truncate px-3 py-1">
|
||||
<a class="hover:underline" href="/subscriptions/show/{row.id}"
|
||||
>{row.a.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-right">
|
||||
{money(code, Number(row.a.amount_min ?? 0))}
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-right">
|
||||
{money(code, Number(row.a.amount_max ?? 0))}
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-cladd-fg-soft">
|
||||
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
|
||||
</td>
|
||||
<td class="px-3 py-1">
|
||||
{#each row.paid as paid (paid.transaction_journal_id)}
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
class="num hover:underline"
|
||||
href="/transactions/show/{paid.transaction_group_id}"
|
||||
>{formatDate(paid.date, "short")}</a
|
||||
>
|
||||
<span class="num text-expense"
|
||||
>{money(
|
||||
paid.currency_code ?? code,
|
||||
-Number(paid.amount ?? 0)
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-cladd-fg-soft">Not charged (yet)</span>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts" module>
|
||||
export type ColumnKind =
|
||||
| "in"
|
||||
| "out"
|
||||
| "net"
|
||||
| "avg-in"
|
||||
| "avg-out"
|
||||
| "pct-in"
|
||||
| "pct-out"
|
||||
| "count";
|
||||
|
||||
export interface Column {
|
||||
kind: ColumnKind;
|
||||
label: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { formatNumber, percent } from "#lib/format.js";
|
||||
import {
|
||||
currencies,
|
||||
type Dimension,
|
||||
type Measure,
|
||||
type Row,
|
||||
rank,
|
||||
type Side,
|
||||
type Totals,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
columns,
|
||||
dim,
|
||||
dir,
|
||||
footer,
|
||||
sort = "out",
|
||||
limit,
|
||||
nameLabel = "Name",
|
||||
counts = false,
|
||||
averageAll = false,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
columns: Column[];
|
||||
dim: Dimension;
|
||||
dir?: 1 | -1;
|
||||
footer?: Totals;
|
||||
sort?: Side;
|
||||
limit?: number;
|
||||
nameLabel?: string;
|
||||
counts?: boolean;
|
||||
averageAll?: boolean;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
let expanded = $state(false);
|
||||
|
||||
const sorted = $derived(rank(rows, report.mode, sort));
|
||||
const shown = $derived(limit && !expanded ? sorted.slice(0, limit) : sorted);
|
||||
|
||||
interface Line {
|
||||
code: string;
|
||||
text: string;
|
||||
tone: string;
|
||||
}
|
||||
|
||||
function cell(
|
||||
kind: ColumnKind,
|
||||
m: Measure,
|
||||
whole: Measure | undefined,
|
||||
code: string
|
||||
): Line | null {
|
||||
if (kind === "count") {
|
||||
return { code, text: formatNumber(m.n, 0), tone: "" };
|
||||
}
|
||||
if (kind === "pct-in" || kind === "pct-out") {
|
||||
const part = kind === "pct-in" ? m.in : m.out;
|
||||
const all = (kind === "pct-in" ? whole?.in : whole?.out) ?? 0;
|
||||
return part && all
|
||||
? { code, text: percent(part / all, 1), tone: "" }
|
||||
: null;
|
||||
}
|
||||
const avg = kind === "avg-in" || kind === "avg-out";
|
||||
const base = { in: m.in, net: m.in - m.out, out: -m.out }[
|
||||
kind.replace("avg-", "") as "in" | "net" | "out"
|
||||
];
|
||||
if (!base || (avg && m.n < (averageAll ? 1 : 2))) {
|
||||
return null;
|
||||
}
|
||||
const value = avg ? base / m.n : base;
|
||||
return {
|
||||
code,
|
||||
text: money(code, value),
|
||||
tone: value < 0 ? "text-expense" : "text-income",
|
||||
};
|
||||
}
|
||||
|
||||
function lines(totals: Totals, kind: ColumnKind): Line[] {
|
||||
return currencies(totals, report.codes).flatMap((code) => {
|
||||
const m = totals.cur.get(code);
|
||||
const line = m ? cell(kind, m, footer?.cur.get(code), code) : null;
|
||||
return line ? [line] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function drill(row: Row) {
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === box && current.key === row.key && current.dim === dim;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same ? null : { box, dim, dir, key: row.key, label: row.label }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">{nameLabel}</th>
|
||||
{#each columns as column (column.kind)}
|
||||
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shown as row (row.key)}
|
||||
{@const active =
|
||||
report.drill?.box === box && report.drill.key === row.key}
|
||||
<tr
|
||||
class="border-cladd-outline border-t align-top hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[18rem] px-3 py-1">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => drill(row)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
{#if counts && row.totals.n > 1}
|
||||
<span class="text-[11px] text-cladd-fg-soft"
|
||||
>{row.totals.n}
|
||||
transactions</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{#each columns as column (column.kind)}
|
||||
<td class="whitespace-nowrap px-3 py-1 text-right">
|
||||
{#each lines(row.totals, column.kind) as line (line.code)}
|
||||
<div class="num {line.tone}">{line.text}</div>
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{#if footer && footer.n > 0}
|
||||
<tfoot>
|
||||
<tr class="border-cladd-outline border-t-2 align-top font-semibold">
|
||||
<td class="px-3 py-1">Sum</td>
|
||||
{#each columns as column (column.kind)}
|
||||
<td class="whitespace-nowrap px-3 py-1 text-right">
|
||||
{#if !(
|
||||
column.kind.startsWith("pct") || column.kind.startsWith("avg")
|
||||
)}
|
||||
{#each lines(footer, column.kind) as line (line.code)}
|
||||
<div class="num {line.tone}">{line.text}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tfoot>
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
{#if limit && sorted.length > limit}
|
||||
<button
|
||||
class="w-full border-cladd-outline border-t px-3 py-1.5 text-left text-[12px] text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? "Show top" : `Show entire list (${sorted.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
|
||||
export interface LedgerRow {
|
||||
after: number;
|
||||
amount: number;
|
||||
before: number;
|
||||
created: string | null;
|
||||
group: string;
|
||||
split: Split;
|
||||
title: string | null;
|
||||
updated: string | null;
|
||||
}
|
||||
|
||||
export const AUDIT_COLUMNS = [
|
||||
{ key: "icon", label: "Icon" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "balance_before", label: "Balance before" },
|
||||
{ key: "amount", label: "Amount" },
|
||||
{ key: "balance_after", label: "Balance after" },
|
||||
{ key: "date", label: "Date" },
|
||||
{ key: "from", label: "From" },
|
||||
{ key: "to", label: "To" },
|
||||
{ key: "budget", label: "Budget" },
|
||||
{ key: "category", label: "Category" },
|
||||
{ key: "bill", label: "Subscription" },
|
||||
{ key: "created_at", label: "Created at" },
|
||||
{ key: "updated_at", label: "Updated at" },
|
||||
{ key: "notes", label: "Notes" },
|
||||
{ key: "interest_date", label: "Interest date" },
|
||||
{ key: "book_date", label: "Book date" },
|
||||
{ key: "process_date", label: "Processing date" },
|
||||
{ key: "due_date", label: "Due date" },
|
||||
{ key: "payment_date", label: "Payment date" },
|
||||
{ key: "invoice_date", label: "Invoice date" },
|
||||
] as const;
|
||||
|
||||
export type AuditColumn = (typeof AUDIT_COLUMNS)[number]["key"];
|
||||
|
||||
export const DEFAULT_AUDIT: AuditColumn[] = [
|
||||
"icon",
|
||||
"description",
|
||||
"balance_before",
|
||||
"amount",
|
||||
"balance_after",
|
||||
"date",
|
||||
"to",
|
||||
];
|
||||
|
||||
function signed(split: Split, account: string, code: string | undefined) {
|
||||
const foreign =
|
||||
split.foreign_amount !== null &&
|
||||
split.foreign_amount !== undefined &&
|
||||
split.foreign_currency_code === code &&
|
||||
split.currency_code !== code;
|
||||
const value = Number(foreign ? split.foreign_amount : split.amount);
|
||||
return String(split.source_id) === account ? -value : value;
|
||||
}
|
||||
|
||||
export function ledger(
|
||||
groups: Item<TransactionGroup>[],
|
||||
account: string,
|
||||
code: string | undefined,
|
||||
start: number
|
||||
): LedgerRow[] {
|
||||
const rows = groups.flatMap((group) =>
|
||||
group.attributes.transactions
|
||||
.filter(
|
||||
(split) =>
|
||||
String(split.source_id) === account ||
|
||||
String(split.destination_id) === account
|
||||
)
|
||||
.map((split) => ({ group, split }))
|
||||
);
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
a.split.date.localeCompare(b.split.date) ||
|
||||
Number(a.split.transaction_journal_id) -
|
||||
Number(b.split.transaction_journal_id)
|
||||
);
|
||||
let balance = start;
|
||||
return rows.map(({ group, split }) => {
|
||||
const amount = signed(split, account, code);
|
||||
const before = balance;
|
||||
balance += amount;
|
||||
return {
|
||||
after: balance,
|
||||
amount,
|
||||
before,
|
||||
created: group.attributes.created_at ?? null,
|
||||
group: group.id,
|
||||
split,
|
||||
title: group.attributes.group_title ?? null,
|
||||
updated: group.attributes.updated_at ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Bill } from "#lib/api/types.js";
|
||||
import { expectedDates } from "./bills.js";
|
||||
|
||||
test("monthly bills land once per month", () => {
|
||||
const bill = {
|
||||
date: "2025-11-03T00:00:00+01:00",
|
||||
repeat_freq: "monthly",
|
||||
skip: 0,
|
||||
} as Bill;
|
||||
expect(
|
||||
expectedDates(bill, { end: "2026-09-30", start: "2026-09-01" })
|
||||
).toEqual(["2026-09-03"]);
|
||||
});
|
||||
|
||||
test("skip and end date are honoured", () => {
|
||||
const bill = {
|
||||
date: "2026-01-31",
|
||||
end_date: "2026-06-01",
|
||||
repeat_freq: "monthly",
|
||||
skip: 1,
|
||||
} as Bill;
|
||||
expect(
|
||||
expectedDates(bill, { end: "2026-12-31", start: "2026-01-01" })
|
||||
).toEqual(["2026-01-31", "2026-03-31", "2026-05-31"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Bill } from "#lib/api/types.js";
|
||||
import { addDays, addMonths, type Range } from "#lib/dates.js";
|
||||
|
||||
const STEP: Record<string, (date: string, n: number) => string> = {
|
||||
"half-year": (date, n) => addMonths(date, 6 * n),
|
||||
monthly: (date, n) => addMonths(date, n),
|
||||
quarterly: (date, n) => addMonths(date, 3 * n),
|
||||
weekly: (date, n) => addDays(date, 7 * n),
|
||||
yearly: (date, n) => addMonths(date, 12 * n),
|
||||
};
|
||||
|
||||
const LIMIT = 500;
|
||||
|
||||
export function expectedDates(bill: Bill, range: Range): string[] {
|
||||
const first = bill.date?.slice(0, 10);
|
||||
const step = STEP[bill.repeat_freq ?? ""];
|
||||
if (!(first && step)) {
|
||||
return [];
|
||||
}
|
||||
const every = (bill.skip ?? 0) + 1;
|
||||
const stop = bill.end_date?.slice(0, 10);
|
||||
const dates: string[] = [];
|
||||
for (let i = 0; i < LIMIT; i += 1) {
|
||||
const date = step(first, i * every);
|
||||
if (date > range.end || (stop && date > stop)) {
|
||||
break;
|
||||
}
|
||||
if (date >= range.start) {
|
||||
dates.push(date);
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import { ledger } from "./audit.js";
|
||||
import {
|
||||
amountOf,
|
||||
bucketRange,
|
||||
buckets,
|
||||
exchanges,
|
||||
group,
|
||||
keys,
|
||||
matches,
|
||||
rank,
|
||||
toFlows,
|
||||
topFlows,
|
||||
total,
|
||||
} from "./flows.js";
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
function tx(split: Partial<Split>): Item<TransactionGroup> {
|
||||
nextId += 1;
|
||||
const id = String(nextId);
|
||||
return {
|
||||
attributes: {
|
||||
transactions: [
|
||||
{
|
||||
amount: "10",
|
||||
currency_code: "UAH",
|
||||
date: "2026-03-05T00:00:00+02:00",
|
||||
description: `tx ${id}`,
|
||||
destination_id: "100",
|
||||
destination_name: "Shop",
|
||||
source_id: "1",
|
||||
source_name: "Mono",
|
||||
tags: [],
|
||||
transaction_journal_id: id,
|
||||
type: "withdrawal",
|
||||
...split,
|
||||
} as Split,
|
||||
],
|
||||
} as TransactionGroup,
|
||||
id,
|
||||
type: "transactions",
|
||||
};
|
||||
}
|
||||
|
||||
const currencyOf = (id: string) => ({ "1": "UAH", "2": "USD", "3": "UAH" })[id];
|
||||
|
||||
const groups = [
|
||||
tx({ amount: "100", category_id: "7", category_name: "Food" }),
|
||||
tx({ amount: "50", tags: ["trip", "fun"] }),
|
||||
tx({
|
||||
amount: "4000",
|
||||
destination_id: "2",
|
||||
destination_name: "Wise",
|
||||
foreign_amount: "100",
|
||||
foreign_currency_code: "USD",
|
||||
type: "transfer",
|
||||
}),
|
||||
tx({
|
||||
amount: "300",
|
||||
destination_id: "1",
|
||||
source_id: "200",
|
||||
source_name: "Employer",
|
||||
type: "deposit",
|
||||
}),
|
||||
tx({
|
||||
amount: "20",
|
||||
date: "2026-04-01T00:00:00+02:00",
|
||||
destination_id: "3",
|
||||
type: "transfer",
|
||||
}),
|
||||
];
|
||||
|
||||
test("only flows that cross the selection count", () => {
|
||||
const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
|
||||
expect(flows.map((flow) => flow.amount)).toEqual([100, 50, 4000, 300]);
|
||||
const totals = total(flows);
|
||||
expect(amountOf(totals, "UAH", "out")).toBe(4150);
|
||||
expect(amountOf(totals, "UAH", "in")).toBe(300);
|
||||
expect(amountOf(totals, "UAH", "net")).toBe(-3850);
|
||||
});
|
||||
|
||||
test("transfers into a selected account use the destination currency", () => {
|
||||
const flows = toFlows(groups, new Set(["2"]), currencyOf);
|
||||
expect(flows).toHaveLength(1);
|
||||
expect(flows[0]).toMatchObject({ amount: 100, currency: "USD", dir: 1 });
|
||||
});
|
||||
|
||||
test("groups by dimension, counting multi-tag journals once per tag", () => {
|
||||
const flows = toFlows(groups, new Set(["1", "3"]), currencyOf);
|
||||
const tags = group(flows, keys.tag);
|
||||
expect(tags.map((row) => [row.label, row.totals.n])).toEqual([
|
||||
["(no tags)", 3],
|
||||
["trip", 1],
|
||||
["fun", 1],
|
||||
]);
|
||||
const categories = rank(group(flows, keys.category), "UAH", "out");
|
||||
expect(categories[0].label).toBe("(no category)");
|
||||
expect(matches(flows[0], "category", "7")).toBe(true);
|
||||
expect(matches(flows[3], "budget", "0")).toBe(false);
|
||||
});
|
||||
|
||||
test("pivots into period columns", () => {
|
||||
const flows = toFlows(groups, new Set(["1"]), currencyOf);
|
||||
const rows = group(flows, keys.category, (flow) => flow.date.slice(0, 7));
|
||||
const none = rows.find((row) => row.key === "0");
|
||||
expect([...(none?.cells.keys() ?? [])]).toEqual(["2026-03", "2026-04"]);
|
||||
expect(amountOf(none?.cells.get("2026-04"), "UAH", "out")).toBe(20);
|
||||
});
|
||||
|
||||
test("buckets cover the range", () => {
|
||||
expect(buckets("2025-11-15", "2026-02-02", "month")).toEqual([
|
||||
"2025-11",
|
||||
"2025-12",
|
||||
"2026-01",
|
||||
"2026-02",
|
||||
]);
|
||||
expect(buckets("2026-02-27", "2026-03-02", "day")).toEqual([
|
||||
"2026-02-27",
|
||||
"2026-02-28",
|
||||
"2026-03-01",
|
||||
"2026-03-02",
|
||||
]);
|
||||
expect(buckets("2024-06-01", "2026-01-01", "year")).toEqual([
|
||||
"2024",
|
||||
"2025",
|
||||
"2026",
|
||||
]);
|
||||
expect(
|
||||
bucketRange("2026-02", "month", { end: "2026-02-20", start: "2026-01-01" })
|
||||
).toEqual({ end: "2026-02-20", start: "2026-02-01" });
|
||||
});
|
||||
|
||||
test("top flows stay inside one currency", () => {
|
||||
const flows = toFlows(groups, new Set(["1"]), currencyOf);
|
||||
expect(topFlows(flows, "UAH", -1, 2).map((flow) => flow.amount)).toEqual([
|
||||
4000, 100,
|
||||
]);
|
||||
expect(topFlows(flows, "USD", -1, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("aggregates a year of transactions quickly", () => {
|
||||
const many = Array.from({ length: 1500 }, (_, i) =>
|
||||
tx({
|
||||
amount: String(i),
|
||||
category_id: String(i % 7),
|
||||
category_name: `c${i % 7}`,
|
||||
date: `2026-${String((i % 12) + 1).padStart(2, "0")}-10T00:00:00+02:00`,
|
||||
tags: [`t${i % 5}`],
|
||||
})
|
||||
);
|
||||
const started = performance.now();
|
||||
const flows = toFlows(many, new Set(["1"]), currencyOf);
|
||||
group(flows, keys.category, (flow) => flow.date.slice(0, 7));
|
||||
group(flows, keys.tag);
|
||||
group(flows, keys.other);
|
||||
expect(performance.now() - started).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("currency exchanges between selected accounts are kept apart", () => {
|
||||
const list = exchanges(groups, new Set(["1", "2"]), currencyOf);
|
||||
expect(list).toEqual([
|
||||
{
|
||||
date: "2026-03-05",
|
||||
from: "UAH",
|
||||
fromAmount: 4000,
|
||||
to: "USD",
|
||||
toAmount: 100,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("audit ledger runs the balance forward in the account currency", () => {
|
||||
const rows = ledger(groups, "1", "UAH", 1000);
|
||||
expect(rows.map((row) => [row.amount, row.after])).toEqual([
|
||||
[-100, 900],
|
||||
[-50, 850],
|
||||
[-4000, -3150],
|
||||
[300, -2850],
|
||||
[-20, -2870],
|
||||
]);
|
||||
const usd = ledger(groups, "2", "USD", 0);
|
||||
expect(usd.map((row) => row.amount)).toEqual([100]);
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import type { Grain } from "./params.js";
|
||||
|
||||
export interface Ref {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Party extends Ref {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
amount: number;
|
||||
asset: Party;
|
||||
bill: Ref | null;
|
||||
budget: Ref | null;
|
||||
category: Ref | null;
|
||||
currency: string;
|
||||
date: string;
|
||||
description: string;
|
||||
dir: 1 | -1;
|
||||
group: string;
|
||||
journal: string;
|
||||
other: Party;
|
||||
pc: number | null;
|
||||
tags: string[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Measure {
|
||||
in: number;
|
||||
n: number;
|
||||
out: number;
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
cur: Map<string, Measure>;
|
||||
n: number;
|
||||
pc: Measure;
|
||||
pcOk: boolean;
|
||||
}
|
||||
|
||||
export interface Key {
|
||||
href?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Row extends Key {
|
||||
cells: Map<string, Totals>;
|
||||
totals: Totals;
|
||||
}
|
||||
|
||||
export type Side = "in" | "out" | "net";
|
||||
|
||||
const NUMERIC = (value: string | null | undefined) =>
|
||||
value === null || value === undefined || value === "" ? null : Number(value);
|
||||
|
||||
const ref = (id: string | null | undefined, name: string | null | undefined) =>
|
||||
id ? { id: String(id), name: name ?? "" } : null;
|
||||
|
||||
function party(split: Split, side: "source" | "destination"): Party {
|
||||
return {
|
||||
id: String(split[`${side}_id`] ?? ""),
|
||||
name: split[`${side}_name`] ?? "",
|
||||
type: split[`${side}_type`] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function inbound(
|
||||
split: Split,
|
||||
currencyOf: (id: string) => string | undefined
|
||||
): { amount: number; currency: string } {
|
||||
const foreign = NUMERIC(split.foreign_amount);
|
||||
const code = split.foreign_currency_code;
|
||||
if (
|
||||
foreign !== null &&
|
||||
code &&
|
||||
currencyOf(String(split.destination_id)) === code
|
||||
) {
|
||||
return { amount: foreign, currency: code };
|
||||
}
|
||||
return { amount: Number(split.amount), currency: split.currency_code ?? "" };
|
||||
}
|
||||
|
||||
function toFlow(
|
||||
split: Split,
|
||||
groupId: string,
|
||||
outgoing: boolean,
|
||||
currencyOf: (id: string) => string | undefined
|
||||
): Flow {
|
||||
const value = outgoing
|
||||
? { amount: Number(split.amount), currency: split.currency_code ?? "" }
|
||||
: inbound(split, currencyOf);
|
||||
return {
|
||||
...value,
|
||||
asset: party(split, outgoing ? "source" : "destination"),
|
||||
bill: ref(split.bill_id, split.bill_name),
|
||||
budget: ref(split.budget_id, split.budget_name),
|
||||
category: ref(split.category_id, split.category_name),
|
||||
date: split.date.slice(0, 10),
|
||||
description: split.description,
|
||||
dir: outgoing ? -1 : 1,
|
||||
group: groupId,
|
||||
journal: String(split.transaction_journal_id ?? ""),
|
||||
other: party(split, outgoing ? "destination" : "source"),
|
||||
pc: NUMERIC(split.pc_amount),
|
||||
tags: split.tags ?? [],
|
||||
type: split.type,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFlows(
|
||||
groups: Item<TransactionGroup>[],
|
||||
selected: ReadonlySet<string>,
|
||||
currencyOf: (id: string) => string | undefined = () => undefined
|
||||
): Flow[] {
|
||||
const flows: Flow[] = [];
|
||||
for (const item of groups) {
|
||||
for (const split of item.attributes.transactions) {
|
||||
const outgoing = selected.has(String(split.source_id));
|
||||
if (outgoing !== selected.has(String(split.destination_id))) {
|
||||
flows.push(toFlow(split, item.id, outgoing, currencyOf));
|
||||
}
|
||||
}
|
||||
}
|
||||
return flows;
|
||||
}
|
||||
|
||||
export function flowValue(flow: Flow, mode: string): number | null {
|
||||
if (mode === "pc") {
|
||||
return flow.pc;
|
||||
}
|
||||
return flow.currency === mode ? flow.amount : null;
|
||||
}
|
||||
|
||||
export const emptyTotals = (): Totals => ({
|
||||
cur: new Map(),
|
||||
n: 0,
|
||||
pc: { in: 0, n: 0, out: 0 },
|
||||
pcOk: true,
|
||||
});
|
||||
|
||||
export function add(totals: Totals, flow: Flow) {
|
||||
let measure = totals.cur.get(flow.currency);
|
||||
if (!measure) {
|
||||
measure = { in: 0, n: 0, out: 0 };
|
||||
totals.cur.set(flow.currency, measure);
|
||||
}
|
||||
const side = flow.dir === 1 ? "in" : "out";
|
||||
measure[side] += flow.amount;
|
||||
measure.n += 1;
|
||||
totals.n += 1;
|
||||
if (flow.pc === null) {
|
||||
totals.pcOk = false;
|
||||
} else {
|
||||
totals.pc[side] += flow.pc;
|
||||
totals.pc.n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function total(flows: Iterable<Flow>): Totals {
|
||||
const totals = emptyTotals();
|
||||
for (const flow of flows) {
|
||||
add(totals, flow);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
export function measureOf(totals: Totals, mode: string): Measure | undefined {
|
||||
return mode === "pc" ? totals.pc : totals.cur.get(mode);
|
||||
}
|
||||
|
||||
export function amountOf(
|
||||
totals: Totals | undefined,
|
||||
mode: string,
|
||||
side: Side
|
||||
): number {
|
||||
const measure = totals ? measureOf(totals, mode) : undefined;
|
||||
if (!measure) {
|
||||
return 0;
|
||||
}
|
||||
if (side === "net") {
|
||||
return measure.in - measure.out;
|
||||
}
|
||||
return measure[side];
|
||||
}
|
||||
|
||||
export type KeyFn = (flow: Flow) => Key | Key[] | null;
|
||||
export type ColFn = (flow: Flow) => string | null;
|
||||
|
||||
export function group(flows: Iterable<Flow>, keyOf: KeyFn, colOf?: ColFn) {
|
||||
const rows = new Map<string, Row>();
|
||||
for (const flow of flows) {
|
||||
const found = keyOf(flow);
|
||||
if (!found) {
|
||||
continue;
|
||||
}
|
||||
const col = colOf?.(flow) ?? null;
|
||||
for (const key of Array.isArray(found) ? found : [found]) {
|
||||
let row = rows.get(key.key);
|
||||
if (!row) {
|
||||
row = { ...key, cells: new Map(), totals: emptyTotals() };
|
||||
rows.set(key.key, row);
|
||||
}
|
||||
add(row.totals, flow);
|
||||
if (col !== null) {
|
||||
let cell = row.cells.get(col);
|
||||
if (!cell) {
|
||||
cell = emptyTotals();
|
||||
row.cells.set(col, cell);
|
||||
}
|
||||
add(cell, flow);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
export function rank<T extends { label: string; totals: Totals }>(
|
||||
rows: T[],
|
||||
mode: string,
|
||||
side: Side
|
||||
): T[] {
|
||||
const score = (row: T) => Math.abs(amountOf(row.totals, mode, side));
|
||||
return [...rows].sort(
|
||||
(a, b) => score(b) - score(a) || a.label.localeCompare(b.label)
|
||||
);
|
||||
}
|
||||
|
||||
export function currencies(totals: Totals, codes?: string[]): string[] {
|
||||
const present = [...totals.cur.keys()];
|
||||
if (!codes) {
|
||||
return present.sort();
|
||||
}
|
||||
return [
|
||||
...codes.filter((code) => totals.cur.has(code)),
|
||||
...present.filter((code) => !codes.includes(code)).sort(),
|
||||
];
|
||||
}
|
||||
|
||||
export const NONE = "0";
|
||||
|
||||
export const keys = {
|
||||
asset: (flow: Flow): Key => ({
|
||||
href: `/accounts/show/${flow.asset.id}`,
|
||||
key: flow.asset.id,
|
||||
label: flow.asset.name,
|
||||
}),
|
||||
bill: (flow: Flow): Key =>
|
||||
flow.bill
|
||||
? {
|
||||
href: `/subscriptions/show/${flow.bill.id}`,
|
||||
key: flow.bill.id,
|
||||
label: flow.bill.name,
|
||||
}
|
||||
: { key: NONE, label: "(no subscription)" },
|
||||
budget: (flow: Flow): Key | null => {
|
||||
if (flow.dir === 1 || flow.type !== "withdrawal") {
|
||||
return null;
|
||||
}
|
||||
return flow.budget
|
||||
? {
|
||||
href: `/budgets/show/${flow.budget.id}`,
|
||||
key: flow.budget.id,
|
||||
label: flow.budget.name,
|
||||
}
|
||||
: { key: NONE, label: "(no budget)" };
|
||||
},
|
||||
category: (flow: Flow): Key =>
|
||||
flow.category
|
||||
? {
|
||||
href: `/categories/show/${flow.category.id}`,
|
||||
key: flow.category.id,
|
||||
label: flow.category.name,
|
||||
}
|
||||
: { key: NONE, label: "(no category)" },
|
||||
name: (flow: Flow): Key => ({
|
||||
key: flow.other.name.toLowerCase(),
|
||||
label: flow.other.name,
|
||||
}),
|
||||
other: (flow: Flow): Key => ({
|
||||
href: `/accounts/show/${flow.other.id}`,
|
||||
key: flow.other.id,
|
||||
label: flow.other.name,
|
||||
}),
|
||||
tag: (flow: Flow): Key[] =>
|
||||
flow.tags.length > 0
|
||||
? flow.tags.map((tag) => ({
|
||||
href: `/tags/show/${encodeURIComponent(tag)}`,
|
||||
key: tag,
|
||||
label: tag,
|
||||
}))
|
||||
: [{ key: NONE, label: "(no tags)" }],
|
||||
};
|
||||
|
||||
export type Dimension = keyof typeof keys;
|
||||
|
||||
export function matches(flow: Flow, dim: Dimension, key: string): boolean {
|
||||
const found = keys[dim](flow);
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(found)
|
||||
? found.some((item) => item.key === key)
|
||||
: found.key === key;
|
||||
}
|
||||
|
||||
export function bucketOf(date: string, grain: Grain): string {
|
||||
if (grain === "year") {
|
||||
return date.slice(0, 4);
|
||||
}
|
||||
return grain === "month" ? date.slice(0, 7) : date.slice(0, 10);
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export function buckets(start: string, end: string, grain: Grain): string[] {
|
||||
const list: string[] = [];
|
||||
if (grain === "year") {
|
||||
for (
|
||||
let y = Number(start.slice(0, 4));
|
||||
y <= Number(end.slice(0, 4));
|
||||
y += 1
|
||||
) {
|
||||
list.push(String(y));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if (grain === "month") {
|
||||
let y = Number(start.slice(0, 4));
|
||||
let m = Number(start.slice(5, 7));
|
||||
const last = end.slice(0, 7);
|
||||
while (`${y}-${pad(m)}` <= last) {
|
||||
list.push(`${y}-${pad(m)}`);
|
||||
m += 1;
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
const d = new Date(
|
||||
Number(start.slice(0, 4)),
|
||||
Number(start.slice(5, 7)) - 1,
|
||||
Number(start.slice(8, 10))
|
||||
);
|
||||
for (;;) {
|
||||
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
if (key > end) {
|
||||
return list;
|
||||
}
|
||||
list.push(key);
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function bucketRange(
|
||||
key: string,
|
||||
grain: Grain,
|
||||
clip?: { start: string; end: string }
|
||||
): { start: string; end: string } {
|
||||
let range: { start: string; end: string };
|
||||
if (grain === "year") {
|
||||
range = { end: `${key}-12-31`, start: `${key}-01-01` };
|
||||
} else if (grain === "month") {
|
||||
const [y, m] = key.split("-").map(Number);
|
||||
range = {
|
||||
end: `${key}-${pad(new Date(y, m, 0).getDate())}`,
|
||||
start: `${key}-01`,
|
||||
};
|
||||
} else {
|
||||
range = { end: key, start: key };
|
||||
}
|
||||
if (!clip) {
|
||||
return range;
|
||||
}
|
||||
return {
|
||||
end: range.end > clip.end ? clip.end : range.end,
|
||||
start: range.start < clip.start ? clip.start : range.start,
|
||||
};
|
||||
}
|
||||
|
||||
export function topFlows(
|
||||
flows: Iterable<Flow>,
|
||||
mode: string,
|
||||
dir: 1 | -1,
|
||||
limit: number
|
||||
): Flow[] {
|
||||
const list: Flow[] = [];
|
||||
for (const flow of flows) {
|
||||
if (flow.dir !== dir) {
|
||||
continue;
|
||||
}
|
||||
if (mode === "pc" ? flow.pc !== null : flow.currency === mode) {
|
||||
list.push(flow);
|
||||
}
|
||||
}
|
||||
const amount = (flow: Flow) => (mode === "pc" ? (flow.pc ?? 0) : flow.amount);
|
||||
return list.sort((a, b) => amount(b) - amount(a)).slice(0, limit);
|
||||
}
|
||||
|
||||
export function modes(totals: Totals, codes: string[]): string[] {
|
||||
const list = currencies(totals, codes);
|
||||
return totals.pcOk && list.length > 1 ? ["pc", ...list] : list;
|
||||
}
|
||||
|
||||
export function daily(flows: Iterable<Flow>, mode: string, dir: 1 | -1) {
|
||||
const days = new Map<string, { value: number; n: number }>();
|
||||
for (const flow of flows) {
|
||||
if (flow.dir !== dir) {
|
||||
continue;
|
||||
}
|
||||
const value = flowValue(flow, mode);
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
const day = days.get(flow.date) ?? { n: 0, value: 0 };
|
||||
day.value += value;
|
||||
day.n += 1;
|
||||
days.set(flow.date, day);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export function merge(list: (Totals | undefined)[]): Totals {
|
||||
const result = emptyTotals();
|
||||
for (const totals of list) {
|
||||
if (!totals) {
|
||||
continue;
|
||||
}
|
||||
for (const [code, m] of totals.cur) {
|
||||
const target = result.cur.get(code) ?? { in: 0, n: 0, out: 0 };
|
||||
target.in += m.in;
|
||||
target.out += m.out;
|
||||
target.n += m.n;
|
||||
result.cur.set(code, target);
|
||||
}
|
||||
result.n += totals.n;
|
||||
result.pc.in += totals.pc.in;
|
||||
result.pc.out += totals.pc.out;
|
||||
result.pc.n += totals.pc.n;
|
||||
result.pcOk &&= totals.pcOk;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
date: string;
|
||||
from: string;
|
||||
fromAmount: number;
|
||||
to: string;
|
||||
toAmount: number;
|
||||
}
|
||||
|
||||
export function exchanges(
|
||||
groups: Item<TransactionGroup>[],
|
||||
selected: ReadonlySet<string>,
|
||||
currencyOf: (id: string) => string | undefined
|
||||
): Exchange[] {
|
||||
const list: Exchange[] = [];
|
||||
for (const item of groups) {
|
||||
for (const split of item.attributes.transactions) {
|
||||
const source = String(split.source_id);
|
||||
const destination = String(split.destination_id);
|
||||
if (!(selected.has(source) && selected.has(destination))) {
|
||||
continue;
|
||||
}
|
||||
const from = split.currency_code ?? currencyOf(source) ?? "";
|
||||
const to = split.foreign_currency_code ?? currencyOf(destination) ?? from;
|
||||
const foreign = NUMERIC(split.foreign_amount);
|
||||
if (from !== to && foreign !== null) {
|
||||
list.push({
|
||||
date: split.date.slice(0, 10),
|
||||
from,
|
||||
fromAmount: Number(split.amount),
|
||||
to,
|
||||
toAmount: foreign,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
kindOf,
|
||||
type ListKind,
|
||||
type ReportParams,
|
||||
type ReportType,
|
||||
} from "./params.js";
|
||||
|
||||
const KEY = "ffui.reports.form";
|
||||
|
||||
export interface Memory {
|
||||
accounts: string[];
|
||||
items: Partial<Record<ListKind, string[]>>;
|
||||
type: ReportType;
|
||||
}
|
||||
|
||||
export function recall(): Memory {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(KEY) ?? "null");
|
||||
if (value && kindOf(value.type) && Array.isArray(value.accounts)) {
|
||||
return {
|
||||
accounts: value.accounts,
|
||||
items: value.items ?? {},
|
||||
type: value.type,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
return { accounts: [], items: {}, type: "default" };
|
||||
}
|
||||
|
||||
export function remember(
|
||||
params: Pick<ReportParams, "type" | "accounts" | "items">
|
||||
) {
|
||||
const memory = recall();
|
||||
const list = kindOf(params.type)?.list;
|
||||
const next: Memory = {
|
||||
accounts: params.accounts,
|
||||
items: list ? { ...memory.items, [list]: params.items } : memory.items,
|
||||
type: params.type,
|
||||
};
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Account, Budget, Category, Item, Tag } from "#lib/api/types.js";
|
||||
import type { ListKind } from "./params.js";
|
||||
import type { Option } from "./report.svelte.js";
|
||||
|
||||
const byLabel = (a: Option, b: Option) =>
|
||||
(a.group ?? "").localeCompare(b.group ?? "") ||
|
||||
a.label.localeCompare(b.label);
|
||||
|
||||
export function budgetOptions(budgets: Item<Budget>[] = []): Option[] {
|
||||
return budgets
|
||||
.filter((b) => b.attributes.active !== false)
|
||||
.map((b) => ({ id: b.id, label: b.attributes.name }))
|
||||
.sort(byLabel);
|
||||
}
|
||||
|
||||
export function categoryOptions(categories: Item<Category>[] = []): Option[] {
|
||||
return categories
|
||||
.map((c) => ({ id: c.id, label: c.attributes.name }))
|
||||
.sort(byLabel);
|
||||
}
|
||||
|
||||
export function tagOptions(tags: Item<Tag>[] = []): Option[] {
|
||||
return tags
|
||||
.map((t) => ({
|
||||
group: t.attributes.date ? t.attributes.date.slice(0, 4) : "(no date)",
|
||||
id: t.id,
|
||||
label: t.attributes.tag,
|
||||
}))
|
||||
.sort(byLabel);
|
||||
}
|
||||
|
||||
export function doubleOptions(
|
||||
expense: Item<Account>[] = [],
|
||||
revenue: Item<Account>[] = []
|
||||
): Option[] {
|
||||
const revenueNames = new Set(
|
||||
revenue.map((a) => a.attributes.name.toLowerCase())
|
||||
);
|
||||
const revenueIbans = new Set(
|
||||
revenue.map((a) => a.attributes.iban).filter(Boolean)
|
||||
);
|
||||
const paired = (a: Account) =>
|
||||
revenueNames.has(a.name.toLowerCase()) ||
|
||||
(!!a.iban && revenueIbans.has(a.iban));
|
||||
const label = (a: Account) => (a.iban ? `${a.name} (${a.iban})` : a.name);
|
||||
const pairs = expense.filter((a) => paired(a.attributes));
|
||||
const pairedNames = new Set(
|
||||
pairs.map((a) => a.attributes.name.toLowerCase())
|
||||
);
|
||||
return [
|
||||
...pairs.map((a) => ({
|
||||
group: "Expense and revenue",
|
||||
id: a.id,
|
||||
label: label(a.attributes),
|
||||
})),
|
||||
...expense
|
||||
.filter((a) => !paired(a.attributes))
|
||||
.map((a) => ({
|
||||
group: "Expense accounts",
|
||||
id: a.id,
|
||||
label: label(a.attributes),
|
||||
})),
|
||||
...revenue
|
||||
.filter((a) => !pairedNames.has(a.attributes.name.toLowerCase()))
|
||||
.map((a) => ({
|
||||
group: "Revenue accounts",
|
||||
id: a.id,
|
||||
label: label(a.attributes),
|
||||
})),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
|
||||
a.label.localeCompare(b.label)
|
||||
);
|
||||
}
|
||||
|
||||
const GROUP_ORDER = [
|
||||
"Expense and revenue",
|
||||
"Expense accounts",
|
||||
"Revenue accounts",
|
||||
];
|
||||
|
||||
export interface ListSources {
|
||||
budgets?: Item<Budget>[];
|
||||
categories?: Item<Category>[];
|
||||
expense?: Item<Account>[];
|
||||
revenue?: Item<Account>[];
|
||||
tags?: Item<Tag>[];
|
||||
}
|
||||
|
||||
export function listOptions(kind: ListKind, sources: ListSources): Option[] {
|
||||
switch (kind) {
|
||||
case "budget":
|
||||
return budgetOptions(sources.budgets);
|
||||
case "category":
|
||||
return categoryOptions(sources.categories);
|
||||
case "tag":
|
||||
return tagOptions(sources.tags);
|
||||
default:
|
||||
return doubleOptions(sources.expense, sources.revenue);
|
||||
}
|
||||
}
|
||||
|
||||
export function listReady(kind: ListKind, sources: ListSources): boolean {
|
||||
switch (kind) {
|
||||
case "budget":
|
||||
return !!sources.budgets;
|
||||
case "category":
|
||||
return !!sources.categories;
|
||||
case "tag":
|
||||
return !!sources.tags;
|
||||
default:
|
||||
return !!(sources.expense && sources.revenue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
fiscalYear,
|
||||
grainOf,
|
||||
parseReport,
|
||||
parseReportDate,
|
||||
reportPath,
|
||||
templateOf,
|
||||
} from "./params.js";
|
||||
|
||||
const NOW = "2026-10-16";
|
||||
|
||||
test("parses Firefly dates and magic words", () => {
|
||||
expect(parseReportDate("20260101", "01-01", NOW)).toBe("2026-01-01");
|
||||
expect(parseReportDate("2026-02-03", "01-01", NOW)).toBe("2026-02-03");
|
||||
expect(parseReportDate("20260231", "01-01", NOW)).toBeNull();
|
||||
expect(parseReportDate("currentMonthStart", "01-01", NOW)).toBe("2026-10-01");
|
||||
expect(parseReportDate("currentMonthEnd", "01-01", NOW)).toBe("2026-10-31");
|
||||
expect(parseReportDate("previousMonthStart", "01-01", NOW)).toBe(
|
||||
"2026-09-01"
|
||||
);
|
||||
expect(parseReportDate("previousMonthEnd", "01-01", NOW)).toBe("2026-09-30");
|
||||
expect(parseReportDate("previousYearEnd", "01-01", NOW)).toBe("2025-12-31");
|
||||
expect(parseReportDate("currentFiscalYearStart", "04-01", NOW)).toBe(
|
||||
"2026-04-01"
|
||||
);
|
||||
expect(parseReportDate("previousFiscalYearEnd", "04-01", NOW)).toBe(
|
||||
"2026-03-31"
|
||||
);
|
||||
});
|
||||
|
||||
test("fiscal year wraps around the start day", () => {
|
||||
expect(fiscalYear("2026-02-10", "04-01")).toEqual({
|
||||
end: "2026-03-31",
|
||||
start: "2025-04-01",
|
||||
});
|
||||
});
|
||||
|
||||
test("round-trips a report path", () => {
|
||||
const parsed = parseReport("category", "1,2/5,7/20260101/20261231");
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (parsed.ok) {
|
||||
expect(parsed.params).toEqual({
|
||||
accounts: ["1", "2"],
|
||||
end: "2026-12-31",
|
||||
items: ["5", "7"],
|
||||
start: "2026-01-01",
|
||||
type: "category",
|
||||
});
|
||||
expect(reportPath(parsed.params)).toBe(
|
||||
"/reports/category/1,2/5,7/20260101/20261231"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects bad addresses like Firefly does", () => {
|
||||
expect(parseReport("nope", "1/20260101/20260131").ok).toBe(false);
|
||||
expect(parseReport("default", "1/20260201/20260101").ok).toBe(false);
|
||||
expect(parseReport("budget", "1/20260101/20260131").ok).toBe(false);
|
||||
const swapped = parseReport("double", "1/9/20260201/20260101");
|
||||
expect(swapped.ok && swapped.params.start).toBe("2026-01-01");
|
||||
});
|
||||
|
||||
test("keeps magic list words for the page to resolve", () => {
|
||||
const parsed = parseReport(
|
||||
"tag",
|
||||
"allAssetAccounts/allTags/20260101/20260131"
|
||||
);
|
||||
expect(parsed.ok && parsed.params.accounts).toEqual(["allAssetAccounts"]);
|
||||
expect(parsed.ok && parsed.params.items).toEqual(["allTags"]);
|
||||
});
|
||||
|
||||
test("picks grain and template like Firefly", () => {
|
||||
expect(grainOf("2026-10-01", "2026-10-31")).toBe("day");
|
||||
expect(templateOf("2026-10-01", "2026-10-31")).toBe("month");
|
||||
expect(grainOf("2026-01-01", "2026-12-31")).toBe("month");
|
||||
expect(templateOf("2026-01-01", "2026-12-31")).toBe("year");
|
||||
expect(templateOf("2026-01-01", "2026-02-28")).toBe("year");
|
||||
expect(grainOf("2025-01-01", "2026-12-31")).toBe("year");
|
||||
expect(templateOf("2025-01-01", "2026-12-31")).toBe("multi-year");
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
addDays,
|
||||
iso,
|
||||
parse,
|
||||
periodOf,
|
||||
type Range,
|
||||
shiftPeriod,
|
||||
today,
|
||||
} from "#lib/dates.js";
|
||||
|
||||
export type ReportType =
|
||||
| "default"
|
||||
| "audit"
|
||||
| "budget"
|
||||
| "category"
|
||||
| "tag"
|
||||
| "double";
|
||||
|
||||
export type ListKind = "budget" | "category" | "tag" | "double";
|
||||
|
||||
export interface ReportKind {
|
||||
label: string;
|
||||
list?: ListKind;
|
||||
type: ReportType;
|
||||
}
|
||||
|
||||
export const REPORT_KINDS: ReportKind[] = [
|
||||
{ label: "Default financial report", type: "default" },
|
||||
{ label: "Transaction history overview (audit)", type: "audit" },
|
||||
{ label: "Budget report", list: "budget", type: "budget" },
|
||||
{ label: "Category report", list: "category", type: "category" },
|
||||
{ label: "Tag report", list: "tag", type: "tag" },
|
||||
{ label: "Expense/revenue account report", list: "double", type: "double" },
|
||||
];
|
||||
|
||||
export const LIST_LABELS: Record<ListKind, { one: string; many: string }> = {
|
||||
budget: { many: "Budgets", one: "budget" },
|
||||
category: { many: "Categories", one: "category" },
|
||||
double: { many: "Expense/revenue accounts", one: "account" },
|
||||
tag: { many: "Tags", one: "tag" },
|
||||
};
|
||||
|
||||
export const ALL_ASSETS = "allAssetAccounts";
|
||||
|
||||
export interface ReportParams {
|
||||
accounts: string[];
|
||||
end: string;
|
||||
items: string[];
|
||||
start: string;
|
||||
type: ReportType;
|
||||
}
|
||||
|
||||
export type Parsed =
|
||||
| { ok: true; params: ReportParams }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const YMD = /^(\d{4})(\d{2})(\d{2})$/;
|
||||
const ISO = /^(\d{4})-(\d{2})-(\d{2})/;
|
||||
const LIST_ITEM = /^[^,/]+$/;
|
||||
|
||||
export const kindOf = (type: string) =>
|
||||
REPORT_KINDS.find((kind) => kind.type === type);
|
||||
|
||||
export const ymd = (value: string) => value.replaceAll("-", "");
|
||||
|
||||
function valid(y: number, m: number, d: number): string | null {
|
||||
const date = new Date(y, m - 1, d);
|
||||
if (
|
||||
date.getFullYear() !== y ||
|
||||
date.getMonth() !== m - 1 ||
|
||||
date.getDate() !== d
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return iso(date);
|
||||
}
|
||||
|
||||
export function fiscalYear(value: string, fiscalStart = "01-01"): Range {
|
||||
const [month, day] = fiscalStart.split("-").map(Number);
|
||||
const year = Number(value.slice(0, 4));
|
||||
const thisYear = iso(new Date(year, (month || 1) - 1, day || 1));
|
||||
const startYear = thisYear <= value ? year : year - 1;
|
||||
const start = iso(new Date(startYear, (month || 1) - 1, day || 1));
|
||||
const next = iso(new Date(startYear + 1, (month || 1) - 1, day || 1));
|
||||
return { end: addDays(next, -1), start };
|
||||
}
|
||||
|
||||
function magic(now: string, fiscalStart: string): Record<string, string> {
|
||||
const d = parse(now);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
const fiscal = fiscalYear(now, fiscalStart);
|
||||
const previousFiscal = fiscalYear(addDays(fiscal.start, -1), fiscalStart);
|
||||
return {
|
||||
currentFiscalYearEnd: fiscal.end,
|
||||
currentFiscalYearStart: fiscal.start,
|
||||
currentMonthEnd: iso(new Date(y, m + 1, 0)),
|
||||
currentMonthStart: iso(new Date(y, m, 1)),
|
||||
currentYearEnd: `${y}-12-31`,
|
||||
currentYearStart: `${y}-01-01`,
|
||||
previousFiscalYearEnd: previousFiscal.end,
|
||||
previousFiscalYearStart: previousFiscal.start,
|
||||
previousMonthEnd: iso(new Date(y, m, 0)),
|
||||
previousMonthStart: iso(new Date(y, m - 1, 1)),
|
||||
previousYearEnd: `${y - 1}-12-31`,
|
||||
previousYearStart: `${y - 1}-01-01`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReportDate(
|
||||
value: string,
|
||||
fiscalStart = "01-01",
|
||||
now = today()
|
||||
): string | null {
|
||||
const word = magic(now, fiscalStart)[value];
|
||||
if (word) {
|
||||
return word;
|
||||
}
|
||||
const match = YMD.exec(value) ?? ISO.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return valid(Number(match[1]), Number(match[2]), Number(match[3]));
|
||||
}
|
||||
|
||||
function list(segment: string | undefined): string[] | null {
|
||||
if (!segment) {
|
||||
return null;
|
||||
}
|
||||
const items = segment
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return items.length > 0 && items.every((item) => LIST_ITEM.test(item))
|
||||
? [...new Set(items)]
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseReport(
|
||||
type: string,
|
||||
rest: string,
|
||||
fiscalStart = "01-01",
|
||||
now = today()
|
||||
): Parsed {
|
||||
const kind = kindOf(type);
|
||||
if (!kind) {
|
||||
return { error: `There is no "${type}" report.`, ok: false };
|
||||
}
|
||||
const segments = rest.split("/").filter(Boolean);
|
||||
const expected = kind.list ? 4 : 3;
|
||||
if (segments.length !== expected) {
|
||||
return { error: "This report address is incomplete.", ok: false };
|
||||
}
|
||||
const accounts = list(segments[0]);
|
||||
const items = kind.list ? list(segments[1]) : [];
|
||||
let start = parseReportDate(segments.at(-2) ?? "", fiscalStart, now);
|
||||
let end = parseReportDate(segments.at(-1) ?? "", fiscalStart, now);
|
||||
if (!accounts) {
|
||||
return { error: "Please select at least one asset account", ok: false };
|
||||
}
|
||||
if (!items) {
|
||||
return {
|
||||
error: `Please select at least one ${LIST_LABELS[kind.list as ListKind].one}`,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
if (!(start && end)) {
|
||||
return {
|
||||
error: "The dates in this report address are not valid.",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
if (end < start) {
|
||||
if (type !== "double") {
|
||||
return {
|
||||
error: "End date of report must be after start date.",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
return { ok: true, params: { accounts, end, items, start, type: kind.type } };
|
||||
}
|
||||
|
||||
export function reportPath(params: ReportParams): string {
|
||||
const parts = [
|
||||
"/reports",
|
||||
params.type,
|
||||
params.accounts.join(","),
|
||||
...(kindOf(params.type)?.list ? [params.items.join(",")] : []),
|
||||
ymd(params.start),
|
||||
ymd(params.end),
|
||||
];
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
export function missing(params: ReportParams): string | null {
|
||||
if (params.accounts.length === 0) {
|
||||
return "Please select at least one asset account";
|
||||
}
|
||||
const kind = kindOf(params.type);
|
||||
if (kind?.list && params.items.length === 0) {
|
||||
return kind.list === "double"
|
||||
? "Please select at least one combination of expense/revenue accounts. If you have none (the list is empty) this report is not available."
|
||||
: `Please select at least one ${LIST_LABELS[kind.list].one}`;
|
||||
}
|
||||
if (!(params.start && params.end)) {
|
||||
return "Please choose a date range";
|
||||
}
|
||||
if (params.end < params.start) {
|
||||
return "End date of report must be after start date.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Grain = "day" | "month" | "year";
|
||||
export type Template = "month" | "year" | "multi-year";
|
||||
|
||||
const daysIn = (y: number, m: number) => new Date(y, m + 1, 0).getDate();
|
||||
|
||||
export function monthsBetween(start: string, end: string): number {
|
||||
const a = parse(start);
|
||||
const b = parse(end);
|
||||
const whole =
|
||||
(b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
|
||||
const days = b.getDate() - a.getDate();
|
||||
return whole + days / daysIn(a.getFullYear(), a.getMonth());
|
||||
}
|
||||
|
||||
const GRAIN_EPSILON = 0.001;
|
||||
|
||||
export function grainOf(start: string, end: string): Grain {
|
||||
const months = monthsBetween(start, end);
|
||||
if (months >= 12 + GRAIN_EPSILON) {
|
||||
return "year";
|
||||
}
|
||||
return months >= 1 + GRAIN_EPSILON ? "month" : "day";
|
||||
}
|
||||
|
||||
export function templateOf(start: string, end: string): Template {
|
||||
const months = monthsBetween(start, end);
|
||||
if (months > 12) {
|
||||
return "multi-year";
|
||||
}
|
||||
return months > 1 ? "year" : "month";
|
||||
}
|
||||
|
||||
const ALIGNED_VIEWS = ["1Y", "6M", "3M", "1M"] as const;
|
||||
|
||||
export function shiftRange(range: Range, dir: 1 | -1): Range {
|
||||
for (const view of ALIGNED_VIEWS) {
|
||||
const aligned = periodOf(range.start, view);
|
||||
if (aligned.start === range.start && aligned.end === range.end) {
|
||||
return shiftPeriod(range, view, dir);
|
||||
}
|
||||
}
|
||||
return shiftPeriod(range, "1M", dir);
|
||||
}
|
||||
|
||||
export interface RangePreset {
|
||||
key: string;
|
||||
label: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export function rangePresets(now = today()): RangePreset[] {
|
||||
const month = periodOf(now, "1M");
|
||||
const year = periodOf(now, "1Y");
|
||||
return [
|
||||
{ key: "this-month", label: "This month", range: month },
|
||||
{
|
||||
key: "last-month",
|
||||
label: "Last month",
|
||||
range: shiftPeriod(month, "1M", -1),
|
||||
},
|
||||
{ key: "this-year", label: "This year", range: year },
|
||||
{
|
||||
key: "last-year",
|
||||
label: "Last year",
|
||||
range: shiftPeriod(year, "1Y", -1),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import type {
|
||||
Account,
|
||||
Bill,
|
||||
BudgetLimit,
|
||||
ChartDataSet,
|
||||
InsightGroupEntry,
|
||||
Item,
|
||||
Page,
|
||||
TransactionGroup,
|
||||
} from "#lib/api/types.js";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
|
||||
const PAGE = 500;
|
||||
|
||||
export async function pages<T>(
|
||||
path: string,
|
||||
query: Record<string, unknown>
|
||||
): Promise<Item<T>[]> {
|
||||
const first = await api<Page<T>>(path, {
|
||||
query: { ...query, limit: PAGE, page: 1 },
|
||||
});
|
||||
const count = first.meta.pagination?.total_pages ?? 1;
|
||||
const rest = await Promise.all(
|
||||
Array.from({ length: Math.max(0, count - 1) }, (_, i) =>
|
||||
api<Page<T>>(path, { query: { ...query, limit: PAGE, page: i + 2 } })
|
||||
)
|
||||
);
|
||||
return [first, ...rest].flatMap((page) => page.data);
|
||||
}
|
||||
|
||||
const balancesAt = async (date: string) => {
|
||||
const [assets, liabilities] = await Promise.all([
|
||||
all<Account>("/v1/accounts", { date, type: "asset" }),
|
||||
all<Account>("/v1/accounts", { date, type: "liabilities" }),
|
||||
]);
|
||||
return new Map(
|
||||
[...assets, ...liabilities].map((item) => [
|
||||
item.id,
|
||||
Number(item.attributes.current_balance ?? 0),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
export const reportQueries = {
|
||||
balances: (start: string, end: string) => ({
|
||||
queryFn: async () => {
|
||||
const [before, after] = await Promise.all([
|
||||
balancesAt(addDays(start, -1)),
|
||||
balancesAt(end),
|
||||
]);
|
||||
return { after, before };
|
||||
},
|
||||
queryKey: ["report", "balances", start, end],
|
||||
}),
|
||||
bills: (start: string, end: string) => ({
|
||||
queryFn: () => all<Bill>("/v1/bills", { end, start }),
|
||||
queryKey: ["report", "bills", start, end],
|
||||
}),
|
||||
chart: (start: string, end: string, period: string, accounts: string[]) => ({
|
||||
enabled: accounts.length > 0,
|
||||
queryFn: () =>
|
||||
api<ChartDataSet[]>("/v1/chart/account/overview", {
|
||||
query: { "accounts[]": accounts, end, period, start },
|
||||
}),
|
||||
queryKey: ["report", "chart", start, end, period, accounts.join(",")],
|
||||
}),
|
||||
first: () => ({
|
||||
queryFn: async () => {
|
||||
const probe = await api<Page<TransactionGroup>>("/v1/transactions", {
|
||||
query: {
|
||||
end: "2038-01-01",
|
||||
limit: 1,
|
||||
start: "1970-01-02",
|
||||
type: "all",
|
||||
},
|
||||
});
|
||||
const last = probe.meta.pagination?.total_pages ?? 1;
|
||||
const page = await api<Page<TransactionGroup>>("/v1/transactions", {
|
||||
query: {
|
||||
end: "2038-01-01",
|
||||
limit: 1,
|
||||
page: last,
|
||||
start: "1970-01-02",
|
||||
type: "all",
|
||||
},
|
||||
});
|
||||
return (
|
||||
page.data[0]?.attributes.transactions[0]?.date.slice(0, 10) ?? null
|
||||
);
|
||||
},
|
||||
queryKey: ["report", "first"],
|
||||
staleTime: 3_600_000,
|
||||
}),
|
||||
ledger: (account: string, start: string, end: string) => ({
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>(`/v1/accounts/${account}/transactions`, {
|
||||
end,
|
||||
start,
|
||||
types: "all",
|
||||
}),
|
||||
queryKey: ["report", "ledger", account, start, end],
|
||||
}),
|
||||
limitSpent: (
|
||||
budget: string,
|
||||
start: string,
|
||||
end: string,
|
||||
accounts: string[]
|
||||
) => ({
|
||||
queryFn: () =>
|
||||
api<InsightGroupEntry[]>("/v1/insight/expense/budget", {
|
||||
query: { "accounts[]": accounts, "budgets[]": [budget], end, start },
|
||||
}),
|
||||
queryKey: ["report", "limit-spent", budget, start, end, accounts.join(",")],
|
||||
}),
|
||||
limits: (start: string, end: string) => ({
|
||||
queryFn: () => all<BudgetLimit>("/v1/budget-limits", { end, start }),
|
||||
queryKey: ["report", "limits", start, end],
|
||||
}),
|
||||
transactions: (start: string, end: string) => ({
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>("/v1/transactions", {
|
||||
end,
|
||||
start,
|
||||
type: "default",
|
||||
}),
|
||||
queryKey: ["report", "transactions", start, end],
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item } from "#lib/api/types.js";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
type Dimension,
|
||||
exchanges,
|
||||
type Flow,
|
||||
matches,
|
||||
modes,
|
||||
toFlows,
|
||||
total,
|
||||
} from "./flows.js";
|
||||
import {
|
||||
ALL_ASSETS,
|
||||
grainOf,
|
||||
kindOf,
|
||||
type ReportParams,
|
||||
reportPath,
|
||||
templateOf,
|
||||
} from "./params.js";
|
||||
import { reportQueries } from "./queries.js";
|
||||
|
||||
export interface Option {
|
||||
group?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const ROLE_GROUPS: Record<string, string> = {
|
||||
cashWalletAsset: "Cash wallets",
|
||||
ccAsset: "Credit cards",
|
||||
defaultAsset: "Default asset accounts",
|
||||
savingAsset: "Savings accounts",
|
||||
sharedAsset: "Shared asset accounts",
|
||||
};
|
||||
|
||||
const LIABILITY_GROUP: Record<string, string> = {
|
||||
debt: "Liability: Debt",
|
||||
loan: "Liability: Loan",
|
||||
mortgage: "Liability: Mortgage",
|
||||
};
|
||||
|
||||
export const LIST_LENGTH = 10;
|
||||
|
||||
export function accountOptions(accounts: Item<Account>[]): Option[] {
|
||||
return accounts
|
||||
.map((item) => {
|
||||
const a = item.attributes;
|
||||
const group =
|
||||
a.type === "liabilities" || a.type === "liability"
|
||||
? (LIABILITY_GROUP[a.liability_type ?? ""] ?? "Liabilities")
|
||||
: (ROLE_GROUPS[a.account_role ?? ""] ?? "Default asset accounts");
|
||||
const shared = a.account_role === "sharedAsset" ? " (shared)" : "";
|
||||
return {
|
||||
group,
|
||||
id: item.id,
|
||||
label: `${a.name}${shared}`,
|
||||
note: [a.currency_code, a.active ? "" : "inactive"]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.group ?? "").localeCompare(b.group ?? "") ||
|
||||
a.label.localeCompare(b.label)
|
||||
);
|
||||
}
|
||||
|
||||
export const isLiability = (account: Account) =>
|
||||
account.type === "liabilities" || account.type === "liability";
|
||||
|
||||
export function allAssetIds(accounts: Item<Account>[]): string[] {
|
||||
return accounts.filter((a) => a.attributes.active).map((a) => a.id);
|
||||
}
|
||||
|
||||
export function currencyMeta(code: string) {
|
||||
const found = session.currencies.find((c) => c.attributes.code === code);
|
||||
return {
|
||||
code,
|
||||
decimals: found?.attributes.decimal_places ?? 2,
|
||||
symbol: found?.attributes.symbol ?? code,
|
||||
};
|
||||
}
|
||||
|
||||
export function money(
|
||||
code: string,
|
||||
amount: number,
|
||||
signed = false,
|
||||
decimals?: number
|
||||
) {
|
||||
const { primary } = session;
|
||||
const meta =
|
||||
code === "pc" && primary
|
||||
? {
|
||||
code: primary.code,
|
||||
decimals: primary.decimal_places,
|
||||
symbol: primary.symbol,
|
||||
}
|
||||
: currencyMeta(code);
|
||||
return formatMoney(
|
||||
{ amount, ...meta, decimals: decimals ?? meta.decimals },
|
||||
signed
|
||||
);
|
||||
}
|
||||
|
||||
export function modeLabel(mode: string) {
|
||||
return mode === "pc" ? `All in ${session.primary?.code ?? ""}` : mode;
|
||||
}
|
||||
|
||||
export function navigate(
|
||||
changes: Record<string, string | null>,
|
||||
path?: string
|
||||
) {
|
||||
const url = new URL(page.url.href);
|
||||
if (path) {
|
||||
url.pathname = path;
|
||||
}
|
||||
for (const [name, value] of Object.entries(changes)) {
|
||||
if (value === null || value === "") {
|
||||
url.searchParams.delete(name);
|
||||
} else {
|
||||
url.searchParams.set(name, value);
|
||||
}
|
||||
}
|
||||
return goto(url, { replace: true, reset: false });
|
||||
}
|
||||
|
||||
export interface Drill {
|
||||
asset?: string;
|
||||
at?: string;
|
||||
box: string;
|
||||
dim?: Dimension | "day" | "flow";
|
||||
dir?: 1 | -1;
|
||||
key?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const DRILL_DIMS = new Set([
|
||||
"asset",
|
||||
"bill",
|
||||
"budget",
|
||||
"category",
|
||||
"name",
|
||||
"other",
|
||||
"tag",
|
||||
"day",
|
||||
"flow",
|
||||
]);
|
||||
|
||||
const DIRS: Record<string, 1 | -1> = { in: 1, out: -1 };
|
||||
const DIR_NAMES = { "-1": "out", "1": "in" } as const;
|
||||
|
||||
function readDrill(url: {
|
||||
searchParams: { get: (name: string) => string | null };
|
||||
}): Drill | null {
|
||||
const box = url.searchParams.get("box");
|
||||
if (!box) {
|
||||
return null;
|
||||
}
|
||||
const raw = url.searchParams.get("drill") ?? "";
|
||||
const split = raw.indexOf(":");
|
||||
const dim = split > 0 ? raw.slice(0, split) : "";
|
||||
const dir = url.searchParams.get("dir");
|
||||
return {
|
||||
asset: url.searchParams.get("acc") ?? undefined,
|
||||
at: url.searchParams.get("at") ?? undefined,
|
||||
box,
|
||||
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
|
||||
dir: DIRS[dir ?? ""],
|
||||
key: split > 0 ? raw.slice(split + 1) : undefined,
|
||||
label: url.searchParams.get("dl") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function drillQuery(drill: Drill | null): Record<string, string | null> {
|
||||
if (!drill) {
|
||||
return { acc: null, at: null, box: null, dir: null, dl: null, drill: null };
|
||||
}
|
||||
return {
|
||||
acc: drill.asset ?? null,
|
||||
at: drill.at ?? null,
|
||||
box: drill.box,
|
||||
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
|
||||
dl: drill.label || null,
|
||||
drill:
|
||||
drill.dim && drill.key !== undefined ? `${drill.dim}:${drill.key}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function drillFilter(
|
||||
drill: Drill,
|
||||
bucketOf: (date: string) => string
|
||||
): (flow: Flow) => boolean {
|
||||
return (flow) => {
|
||||
if (drill.dir && flow.dir !== drill.dir) {
|
||||
return false;
|
||||
}
|
||||
if (drill.at && bucketOf(flow.date) !== drill.at) {
|
||||
return false;
|
||||
}
|
||||
if (drill.asset && flow.asset.id !== drill.asset) {
|
||||
return false;
|
||||
}
|
||||
if (!drill.dim || drill.key === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (drill.dim === "day") {
|
||||
return flow.date === drill.key;
|
||||
}
|
||||
if (drill.dim === "flow") {
|
||||
return flow.journal === drill.key;
|
||||
}
|
||||
return matches(flow, drill.dim, drill.key);
|
||||
};
|
||||
}
|
||||
|
||||
function twins(
|
||||
ids: string[],
|
||||
expense: Item<Account>[],
|
||||
revenue: Item<Account>[]
|
||||
): Set<string> {
|
||||
const pool = [...expense, ...revenue];
|
||||
const chosen = pool.filter((a) => ids.includes(a.id));
|
||||
const names = new Set(chosen.map((a) => a.attributes.name.toLowerCase()));
|
||||
const ibans = new Set(
|
||||
chosen
|
||||
.map((a) => a.attributes.iban)
|
||||
.filter((iban): iban is string => !!iban)
|
||||
);
|
||||
return new Set(
|
||||
pool
|
||||
.filter(
|
||||
(a) =>
|
||||
ids.includes(a.id) ||
|
||||
names.has(a.attributes.name.toLowerCase()) ||
|
||||
(a.attributes.iban && ibans.has(a.attributes.iban))
|
||||
)
|
||||
.map((a) => a.id)
|
||||
);
|
||||
}
|
||||
|
||||
export function createReport(params: () => ReportParams) {
|
||||
const p = $derived(params());
|
||||
const kind = $derived(kindOf(p.type));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
const budgets = createQuery(() => ({
|
||||
...lists.budgets(),
|
||||
enabled: p.type === "budget" || p.type === "default",
|
||||
}));
|
||||
const categories = createQuery(() => ({
|
||||
...lists.categories(),
|
||||
enabled: p.type === "category",
|
||||
}));
|
||||
const tags = createQuery(() => ({
|
||||
...lists.tags(),
|
||||
enabled: p.type === "tag",
|
||||
}));
|
||||
const expense = createQuery(() => ({
|
||||
...lists.accounts("expense"),
|
||||
enabled: p.type === "double",
|
||||
}));
|
||||
const revenue = createQuery(() => ({
|
||||
...lists.accounts("revenue"),
|
||||
enabled: p.type === "double",
|
||||
}));
|
||||
const tx = createQuery(() => ({
|
||||
...reportQueries.transactions(p.start, p.end),
|
||||
enabled: p.type !== "audit",
|
||||
}));
|
||||
|
||||
const accounts = $derived([
|
||||
...(assets.data ?? []),
|
||||
...(liabilities.data ?? []),
|
||||
]);
|
||||
const accountsReady = $derived(!!(assets.data && liabilities.data));
|
||||
const byId = $derived(new Map(accounts.map((a) => [a.id, a.attributes])));
|
||||
const selected = $derived(
|
||||
p.accounts.includes(ALL_ASSETS) ? allAssetIds(accounts) : p.accounts
|
||||
);
|
||||
const selectedSet = $derived(new Set(selected));
|
||||
const items = $derived.by(() => {
|
||||
const raw = p.items;
|
||||
if (p.type === "budget") {
|
||||
return raw.includes("allBudgets")
|
||||
? (budgets.data ?? [])
|
||||
.filter((b) => b.attributes.active)
|
||||
.map((b) => b.id)
|
||||
: raw;
|
||||
}
|
||||
if (p.type === "category") {
|
||||
return raw.includes("allCategories")
|
||||
? (categories.data ?? []).map((c) => c.id)
|
||||
: raw;
|
||||
}
|
||||
if (p.type === "tag") {
|
||||
const list = tags.data ?? [];
|
||||
if (raw.includes("allTags")) {
|
||||
return list.map((t) => t.id);
|
||||
}
|
||||
return raw.map(
|
||||
(value) =>
|
||||
list.find(
|
||||
(t) =>
|
||||
t.id === value ||
|
||||
t.attributes.tag.toLowerCase() === value.toLowerCase()
|
||||
)?.id ?? value
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
const itemSet = $derived(new Set(items));
|
||||
const itemNames = $derived.by(() => {
|
||||
const names = new Map<string, string>();
|
||||
for (const b of budgets.data ?? []) {
|
||||
names.set(`budget:${b.id}`, b.attributes.name);
|
||||
}
|
||||
for (const c of categories.data ?? []) {
|
||||
names.set(`category:${c.id}`, c.attributes.name);
|
||||
}
|
||||
for (const t of tags.data ?? []) {
|
||||
names.set(`tag:${t.id}`, t.attributes.tag);
|
||||
}
|
||||
for (const a of [...(expense.data ?? []), ...(revenue.data ?? [])]) {
|
||||
names.set(`double:${a.id}`, a.attributes.name);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
const tagNames = $derived(
|
||||
new Set(
|
||||
(tags.data ?? [])
|
||||
.filter((t) => itemSet.has(t.id))
|
||||
.map((t) => t.attributes.tag)
|
||||
)
|
||||
);
|
||||
const counterparts = $derived(
|
||||
p.type === "double"
|
||||
? twins(items, expense.data ?? [], revenue.data ?? [])
|
||||
: new Set<string>()
|
||||
);
|
||||
|
||||
const flows = $derived(
|
||||
tx.data && accountsReady
|
||||
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const swaps = $derived(
|
||||
tx.data && accountsReady
|
||||
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
: []
|
||||
);
|
||||
|
||||
const listReady = $derived(
|
||||
{
|
||||
audit: true,
|
||||
budget: !!budgets.data,
|
||||
category: !!categories.data,
|
||||
default: true,
|
||||
double: !!(expense.data && revenue.data),
|
||||
tag: !!tags.data,
|
||||
}[p.type]
|
||||
);
|
||||
|
||||
const scoped = $derived.by(() => {
|
||||
if (!(flows && listReady)) {
|
||||
return;
|
||||
}
|
||||
switch (p.type) {
|
||||
case "budget":
|
||||
return flows.filter(
|
||||
(f) => f.dir === -1 && f.budget && itemSet.has(f.budget.id)
|
||||
);
|
||||
case "category":
|
||||
return flows.filter((f) => f.category && itemSet.has(f.category.id));
|
||||
case "tag":
|
||||
return flows.filter((f) => f.tags.some((t) => tagNames.has(t)));
|
||||
case "double":
|
||||
return flows.filter((f) => counterparts.has(f.other.id));
|
||||
default:
|
||||
return flows;
|
||||
}
|
||||
});
|
||||
|
||||
const totals = $derived(scoped ? total(scoped) : undefined);
|
||||
const codes = $derived.by(() => {
|
||||
const primary = session.primary?.code;
|
||||
const all = session.currencies.map((c) => c.attributes.code);
|
||||
return primary ? [primary, ...all.filter((c) => c !== primary)] : all;
|
||||
});
|
||||
const available = $derived(totals ? modes(totals, codes) : []);
|
||||
const mode = $derived.by(() => {
|
||||
const wanted = page.url.searchParams.get("cur");
|
||||
if (wanted && available.includes(wanted)) {
|
||||
return wanted;
|
||||
}
|
||||
return available[0] ?? session.primary?.code ?? "";
|
||||
});
|
||||
const grain = $derived(grainOf(p.start, p.end));
|
||||
const template = $derived(templateOf(p.start, p.end));
|
||||
const drill = $derived(readDrill(page.url));
|
||||
const delta = $derived(page.url.searchParams.get("delta"));
|
||||
|
||||
return {
|
||||
accounts: () => accounts,
|
||||
get accountsReady() {
|
||||
return accountsReady;
|
||||
},
|
||||
get available() {
|
||||
return available;
|
||||
},
|
||||
get budgets() {
|
||||
return budgets.data;
|
||||
},
|
||||
byId: () => byId,
|
||||
get categories() {
|
||||
return categories.data;
|
||||
},
|
||||
get codes() {
|
||||
return codes;
|
||||
},
|
||||
get counterparts() {
|
||||
return counterparts;
|
||||
},
|
||||
get delta() {
|
||||
return delta;
|
||||
},
|
||||
get drill() {
|
||||
return drill;
|
||||
},
|
||||
get error() {
|
||||
return tx.error ?? assets.error ?? null;
|
||||
},
|
||||
get exchanges() {
|
||||
return swaps;
|
||||
},
|
||||
get expense() {
|
||||
return expense.data;
|
||||
},
|
||||
get flows() {
|
||||
return flows;
|
||||
},
|
||||
get grain() {
|
||||
return grain;
|
||||
},
|
||||
get groups() {
|
||||
return tx.data;
|
||||
},
|
||||
itemName: (kindKey: string, id: string) =>
|
||||
itemNames.get(`${kindKey}:${id}`),
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get kind() {
|
||||
return kind;
|
||||
},
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
get params() {
|
||||
return p;
|
||||
},
|
||||
path: (changes: Partial<ReportParams>) => reportPath({ ...p, ...changes }),
|
||||
get revenue() {
|
||||
return revenue.data;
|
||||
},
|
||||
get scoped() {
|
||||
return scoped;
|
||||
},
|
||||
get selected() {
|
||||
return selected;
|
||||
},
|
||||
get tags() {
|
||||
return tags.data;
|
||||
},
|
||||
get template() {
|
||||
return template;
|
||||
},
|
||||
get totals() {
|
||||
return totals;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Report = ReturnType<typeof createReport>;
|
||||
|
||||
const KEY = Symbol("report");
|
||||
|
||||
export const provideReport = (report: Report) => setContext(KEY, report);
|
||||
export const useReport = () => getContext<Report>(KEY);
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { StackKey, StackRow } from "#lib/components/charts/chart.js";
|
||||
import { tones } from "#lib/components/charts/chart.js";
|
||||
import { compactMoney, formatDate } from "#lib/format.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { bucketOf, type Flow, flowValue } from "./flows.js";
|
||||
import type { Grain } from "./params.js";
|
||||
import { currencyMeta, money } from "./report.svelte.js";
|
||||
|
||||
export function bucketLabel(key: string, grain: Grain, short = false): string {
|
||||
if (grain === "year") {
|
||||
return key;
|
||||
}
|
||||
if (grain === "month") {
|
||||
const date = `${key}-01`;
|
||||
return short
|
||||
? new Intl.DateTimeFormat(session.locale, {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}).format(
|
||||
new Date(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, 1)
|
||||
)
|
||||
: formatDate(date, "month");
|
||||
}
|
||||
return short ? formatDate(key, "short") : formatDate(key, "day");
|
||||
}
|
||||
|
||||
export const FLOW_KEYS: StackKey[] = [
|
||||
{ color: tones.income, key: "in", label: "Earned" },
|
||||
{ color: tones.expense, key: "out", label: "Spent" },
|
||||
];
|
||||
|
||||
export function bucketRows(
|
||||
flows: Iterable<Flow>,
|
||||
keys: string[],
|
||||
grain: Grain,
|
||||
mode: string
|
||||
): StackRow[] {
|
||||
const sums = new Map(keys.map((key) => [key, { in: 0, out: 0 }]));
|
||||
for (const flow of flows) {
|
||||
const value = flowValue(flow, mode);
|
||||
const bucket = sums.get(bucketOf(flow.date, grain));
|
||||
if (value === null || !bucket) {
|
||||
continue;
|
||||
}
|
||||
bucket[flow.dir === 1 ? "in" : "out"] += value;
|
||||
}
|
||||
return keys.map((key) => {
|
||||
const sum = sums.get(key) ?? { in: 0, out: 0 };
|
||||
return {
|
||||
key,
|
||||
label: bucketLabel(key, grain, true),
|
||||
values: { in: sum.in, out: -sum.out },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function modeFormat(mode: string) {
|
||||
return (value: number) => money(mode, value);
|
||||
}
|
||||
|
||||
export function modeAxis(mode: string) {
|
||||
const symbol =
|
||||
mode === "pc" ? (session.primary?.symbol ?? "") : currencyMeta(mode).symbol;
|
||||
return (value: number) => compactMoney(value, symbol);
|
||||
}
|
||||
|
||||
export function upright(rows: StackRow[]): StackRow[] {
|
||||
if (rows.some((row) => (row.values.in ?? 0) !== 0)) {
|
||||
return rows;
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
values: { out: Math.abs(row.values.out ?? 0) },
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import MultiSelect from "#lib/components/reports/MultiSelect.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { today } from "#lib/dates.js";
|
||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { recall, remember } from "#lib/reports/memory.js";
|
||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
||||
import {
|
||||
fiscalYear,
|
||||
LIST_LABELS,
|
||||
type ListKind,
|
||||
missing,
|
||||
REPORT_KINDS,
|
||||
type ReportParams,
|
||||
type ReportType,
|
||||
rangePresets,
|
||||
reportPath,
|
||||
} from "#lib/reports/params.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { accountOptions } from "#lib/reports/report.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const memory = recall();
|
||||
let type = $state<ReportType>(memory.type);
|
||||
let accounts = $state<string[]>(memory.accounts);
|
||||
let items = $state<Partial<Record<ListKind, string[]>>>(memory.items);
|
||||
let start = $derived(period.start);
|
||||
let end = $derived(period.end);
|
||||
let problem = $state("");
|
||||
|
||||
const kind = $derived(REPORT_KINDS.find((k) => k.type === type));
|
||||
const list = $derived(kind?.list);
|
||||
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
const budgets = createQuery(() => ({
|
||||
...lists.budgets(),
|
||||
enabled: list === "budget",
|
||||
}));
|
||||
const categories = createQuery(() => ({
|
||||
...lists.categories(),
|
||||
enabled: list === "category",
|
||||
}));
|
||||
const tags = createQuery(() => ({
|
||||
...lists.tags(),
|
||||
enabled: list === "tag",
|
||||
}));
|
||||
const expense = createQuery(() => ({
|
||||
...lists.accounts("expense"),
|
||||
enabled: list === "double",
|
||||
}));
|
||||
const revenue = createQuery(() => ({
|
||||
...lists.accounts("revenue"),
|
||||
enabled: list === "double",
|
||||
}));
|
||||
const first = createQuery(() => reportQueries.first());
|
||||
|
||||
const all = $derived([...(assets.data ?? []), ...(liabilities.data ?? [])]);
|
||||
const accountReady = $derived(!!(assets.data && liabilities.data));
|
||||
const options = $derived(accountOptions(all));
|
||||
const sources = $derived({
|
||||
budgets: budgets.data,
|
||||
categories: categories.data,
|
||||
expense: expense.data,
|
||||
revenue: revenue.data,
|
||||
tags: tags.data,
|
||||
});
|
||||
const itemOptions = $derived(list ? listOptions(list, sources) : []);
|
||||
const allIds = $derived(all.map((a) => a.id).join(","));
|
||||
const fiscal = $derived(
|
||||
session.prefs.customFiscalYear
|
||||
? String(session.prefs.fiscalYearStart ?? "01-01")
|
||||
: null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (accountReady && accounts.length === 0 && memory.accounts.length === 0) {
|
||||
accounts = (assets.data ?? [])
|
||||
.filter((a) => a.attributes.active)
|
||||
.map((a) => a.id);
|
||||
}
|
||||
});
|
||||
|
||||
const params = $derived<ReportParams>({
|
||||
accounts: accounts.filter(
|
||||
(id) => all.length === 0 || all.some((a) => a.id === id)
|
||||
),
|
||||
end,
|
||||
items: list ? (items[list] ?? []) : [],
|
||||
start,
|
||||
type,
|
||||
});
|
||||
|
||||
const years = $derived.by(() => {
|
||||
const now = Number(today().slice(0, 4));
|
||||
const from = Number((first.data ?? today()).slice(0, 4));
|
||||
return Array.from(
|
||||
{ length: Math.max(1, now - from + 1) },
|
||||
(_, i) => from + i
|
||||
);
|
||||
});
|
||||
const monthName = $derived(
|
||||
new Intl.DateTimeFormat(session.locale, { month: "short" })
|
||||
);
|
||||
|
||||
function setRange(range: { start: string; end: string }) {
|
||||
({ start, end } = range);
|
||||
problem = "";
|
||||
}
|
||||
|
||||
function monthRange(year: number, month: number) {
|
||||
const last = new Date(year, month + 1, 0).getDate();
|
||||
const mm = String(month + 1).padStart(2, "0");
|
||||
return { end: `${year}-${mm}-${last}`, start: `${year}-${mm}-01` };
|
||||
}
|
||||
|
||||
function quarterRange(year: number, quarter: number) {
|
||||
const a = monthRange(year, quarter * 3);
|
||||
const b = monthRange(year, quarter * 3 + 2);
|
||||
return { end: b.end, start: a.start };
|
||||
}
|
||||
|
||||
function submit() {
|
||||
problem = missing(params) ?? "";
|
||||
if (problem) {
|
||||
return;
|
||||
}
|
||||
remember(params);
|
||||
goto(reportPath(params));
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
input: true,
|
||||
keys: "mod+Enter",
|
||||
label: "Show report",
|
||||
run: submit,
|
||||
},
|
||||
]);
|
||||
|
||||
const quick = $derived([
|
||||
{
|
||||
label: "Current month, all accounts",
|
||||
range: "currentMonthStart/currentMonthEnd",
|
||||
},
|
||||
{
|
||||
label: "Current year, all accounts",
|
||||
range: "currentYearStart/currentYearEnd",
|
||||
},
|
||||
...(fiscal
|
||||
? [
|
||||
{
|
||||
label: "Current fiscal year, all accounts",
|
||||
range: "currentFiscalYearStart/currentFiscalYearEnd",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Last month, all accounts",
|
||||
only: "default",
|
||||
range: "previousMonthStart/previousMonthEnd",
|
||||
},
|
||||
{
|
||||
label: "All-time, all accounts",
|
||||
range: `${(first.data ?? today()).replaceAll("-", "")}/currentMonthEnd`,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<PageHeader crumbs={[{ label: "Reports" }]} title="Reports" />
|
||||
|
||||
<div class="grid items-start gap-4 lg:grid-cols-2">
|
||||
<form
|
||||
class="flex min-w-0 flex-col gap-4"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Box body="p-4" title="Reports">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Report type</span
|
||||
>
|
||||
<select
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={() => (problem = "")}
|
||||
bind:value={type}
|
||||
>
|
||||
{#each REPORT_KINDS as option (option.type)}
|
||||
<option value={option.type}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
for="gen-accounts"
|
||||
>Included accounts</label
|
||||
>
|
||||
<MultiSelect
|
||||
id="gen-accounts"
|
||||
label="Included accounts"
|
||||
loading={!accountReady}
|
||||
onchange={(next) => {
|
||||
accounts = next;
|
||||
problem = "";
|
||||
}}
|
||||
{options}
|
||||
value={params.accounts}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Date range</span
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Start date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
min={first.data ?? undefined}
|
||||
type="date"
|
||||
bind:value={start}
|
||||
>
|
||||
<span class="text-cladd-fg-soft">to</span>
|
||||
<input
|
||||
aria-label="End date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={end}
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[12px]">
|
||||
{#each rangePresets() as preset (preset.key)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline {preset
|
||||
.range.start === start && preset.range.end === end
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => setRange(preset.range)}
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Pre-set ranges</span
|
||||
>
|
||||
<ul class="flex flex-col gap-1 text-[12px]">
|
||||
{#each years as year (year)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<button
|
||||
class="num font-semibold text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
setRange(
|
||||
fiscal
|
||||
? fiscalYear(`${year}-12-31`, fiscal)
|
||||
: { end: `${year}-12-31`, start: `${year}-01-01` }
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{year}{fiscal ? " (fiscal year)" : ""}
|
||||
</button>
|
||||
{#each [0, 1, 2, 3] as q (q)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => setRange(quarterRange(year, q))}
|
||||
type="button"
|
||||
>
|
||||
Q{q + 1}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="text-cladd-fg-softer">·</span>
|
||||
{#each Array.from({ length: 12 }, (_, m) => m) as m (m)}
|
||||
{#if `${year}-${String(m + 1).padStart(2, "0")}` <=
|
||||
today().slice(0, 7)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => setRange(monthRange(year, m))}
|
||||
title="{monthName.format(new Date(year, m, 1))} {year}"
|
||||
type="button"
|
||||
>
|
||||
{monthName.format(new Date(year, m, 1))}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box body="p-4" title="Extra options">
|
||||
{#if list}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
for="gen-items"
|
||||
>Select {LIST_LABELS[list].many.toLowerCase()}</label
|
||||
>
|
||||
<MultiSelect
|
||||
id="gen-items"
|
||||
label={LIST_LABELS[list].many}
|
||||
loading={!listReady(list, sources)}
|
||||
onchange={(next) => {
|
||||
items = { ...items, [list]: next };
|
||||
problem = "";
|
||||
}}
|
||||
options={itemOptions}
|
||||
value={params.items}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-cladd-fg-soft">This report has no extra options</p>
|
||||
{/if}
|
||||
</Box>
|
||||
|
||||
<Box body="p-4" title="View report">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm px-4 font-semibold text-[13px] text-cladd-primary"
|
||||
type="submit"
|
||||
>
|
||||
Show report
|
||||
</button>
|
||||
<span class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
|
||||
{#each prettyKeys("mod+Enter") as part (part)}
|
||||
<kbd class="kbd">{part}</kbd>
|
||||
{/each}
|
||||
</span>
|
||||
{#if problem}
|
||||
<span class="text-expense" role="alert">{problem}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<Box body="p-4" title="Quick links">
|
||||
{#if accountReady && all.length === 0}
|
||||
<p class="text-expense">
|
||||
In order to generate reports, you need to add at least one asset account
|
||||
to Firefly III.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each [
|
||||
{ label: "Default financial report", type: "default" },
|
||||
{ label: "Transaction history overview", type: "audit" },
|
||||
] as section (section.type)}
|
||||
<section>
|
||||
<h3 class="mb-1 font-semibold text-[13px]">{section.label}</h3>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each quick.filter(
|
||||
(q) => !q.only || q.only === section.type
|
||||
) as link (link.label)}
|
||||
<li>
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/reports/{section.type}/{allIds}/{link.range}"
|
||||
>{link.label}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
<p class="text-[12px] text-cladd-fg-soft">
|
||||
Remember that reports can be bookmarked.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Box>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import ReportPage from "#lib/components/reports/ReportPage.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { parseReport } from "#lib/reports/params.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const fiscal = $derived(
|
||||
session.prefs.customFiscalYear
|
||||
? String(session.prefs.fiscalYearStart ?? "01-01")
|
||||
: "01-01"
|
||||
);
|
||||
const parsed = $derived(
|
||||
parseReport(page.params.type ?? "", page.params.rest ?? "", fiscal)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if parsed.ok}
|
||||
<ReportPage params={parsed.params} />
|
||||
{:else}
|
||||
<PageHeader
|
||||
crumbs={[{ href: "/reports", label: "Reports" }]}
|
||||
title="Reports"
|
||||
/>
|
||||
<Box class="max-w-xl" title="This report cannot be shown">
|
||||
<p class="mb-2">{parsed.error}</p>
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/reports"
|
||||
>Back to the report generator</a
|
||||
>
|
||||
</Box>
|
||||
{/if}
|
||||
Reference in new issue
Block a user