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