feat(reports): a year board per currency instead of the generator
/reports opens the year: per currency spent so far, a typical month and this month against typical by today, one lane of category columns per currency with a dotted typical line and income as dots, what grew next to the month under the cursor, an honest by-currency table and net worth lanes weighted by size. Arrows move the month, Enter opens its transactions, [ ] change the year, m shows the old tables, ?view=month pins a month with the money flow. Old report URLs still work. Shared pieces for other pages: Lanes, ledgerQuery for twelve months, typical-month helpers.
This commit is contained in:
1 parent
8c4e72666b
commit
26903b0066
20 files changed
+2519
-593
No files matched your search
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
type Lane,
|
||||
OTHER_COLOR,
|
||||
type StackKey,
|
||||
seriesColor,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import { addMonths, periodOf, type Range, today } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { buckets, type Flow, keys, NONE } from "./flows.js";
|
||||
import { money } from "./report.svelte.js";
|
||||
import {
|
||||
type Pace,
|
||||
pace,
|
||||
series,
|
||||
typical,
|
||||
typicalMonths,
|
||||
typicalToDay,
|
||||
versus,
|
||||
} from "./typical.js";
|
||||
import { modeAxis } from "./view.js";
|
||||
|
||||
export const OTHER = "other";
|
||||
const NO_CATEGORY_COLOR = "var(--cladd-fg-softer)";
|
||||
const MAX_CATEGORIES = 6;
|
||||
const TOP_PLACES = 3;
|
||||
|
||||
export const monthOf = (date: string) => date.slice(0, 7);
|
||||
|
||||
export const shiftMonth = (month: string, delta: number) =>
|
||||
monthOf(addMonths(`${month}-01`, delta));
|
||||
|
||||
export const monthRange = (month: string): Range =>
|
||||
periodOf(`${month}-01`, "1M");
|
||||
|
||||
export function cursorOf(range: Range, now = today()): string {
|
||||
const start = monthOf(range.start);
|
||||
if (start === monthOf(range.end)) {
|
||||
return start;
|
||||
}
|
||||
if (range.start <= now && now <= range.end) {
|
||||
return monthOf(now);
|
||||
}
|
||||
return monthOf(range.end) > monthOf(now) ? monthOf(now) : monthOf(range.end);
|
||||
}
|
||||
|
||||
export const shortMonth = (month: string) =>
|
||||
new Intl.DateTimeFormat(session.locale, { month: "short" }).format(
|
||||
new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1)
|
||||
);
|
||||
|
||||
export const longMonth = (month: string) => formatDate(`${month}-01`, "month");
|
||||
|
||||
const ordinals = new Intl.PluralRules("en", { type: "ordinal" });
|
||||
const SUFFIX: Record<string, string> = {
|
||||
few: "rd",
|
||||
one: "st",
|
||||
other: "th",
|
||||
two: "nd",
|
||||
};
|
||||
|
||||
export const ordinal = (n: number) => `${n}${SUFFIX[ordinals.select(n)]}`;
|
||||
|
||||
const key = (code: string, part: string) => `${code} ${part}`;
|
||||
|
||||
export interface Board {
|
||||
categories: Map<string, Map<string, number>>;
|
||||
catKeys: StackKey[];
|
||||
catLabel: Map<string, string>;
|
||||
codes: string[];
|
||||
earned: Map<string, Map<string, number>>;
|
||||
months: string[];
|
||||
now: string;
|
||||
partial: string | null;
|
||||
primary: string;
|
||||
spending: Flow[];
|
||||
spent: Map<string, Map<string, number>>;
|
||||
stackOf: (flow: Flow) => string;
|
||||
stacks: Map<string, Map<string, number>>;
|
||||
typical: Map<string, number>;
|
||||
typicalMonths: string[];
|
||||
}
|
||||
|
||||
function rankCategories(spending: Flow[], months: Set<string>) {
|
||||
const totals = new Map<string, number>();
|
||||
const byCat = new Map<string, number>();
|
||||
const labels = new Map<string, string>([
|
||||
[NONE, "(no category)"],
|
||||
[OTHER, "Other"],
|
||||
]);
|
||||
for (const flow of spending) {
|
||||
if (!months.has(monthOf(flow.date))) {
|
||||
continue;
|
||||
}
|
||||
const cat = keys.category(flow);
|
||||
labels.set(cat.key, cat.label);
|
||||
totals.set(flow.currency, (totals.get(flow.currency) ?? 0) + flow.amount);
|
||||
const k = key(flow.currency, cat.key);
|
||||
byCat.set(k, (byCat.get(k) ?? 0) + flow.amount);
|
||||
}
|
||||
const score = new Map<string, number>();
|
||||
for (const [k, value] of byCat) {
|
||||
const [code, cat] = [
|
||||
k.slice(0, k.indexOf(" ")),
|
||||
k.slice(k.indexOf(" ") + 1),
|
||||
];
|
||||
score.set(cat, (score.get(cat) ?? 0) + value / (totals.get(code) || 1));
|
||||
}
|
||||
const ranked = [...score.entries()]
|
||||
.filter(([cat]) => cat !== NONE)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([cat]) => cat);
|
||||
const top = new Set(ranked.slice(0, MAX_CATEGORIES));
|
||||
const catKeys: StackKey[] = [...top].map((cat, i) => ({
|
||||
color: seriesColor(i),
|
||||
key: cat,
|
||||
label: labels.get(cat) ?? cat,
|
||||
}));
|
||||
if (score.has(NONE)) {
|
||||
catKeys.push({
|
||||
color: NO_CATEGORY_COLOR,
|
||||
key: NONE,
|
||||
label: labels.get(NONE) ?? "",
|
||||
});
|
||||
}
|
||||
if (ranked.length > top.size) {
|
||||
catKeys.push({ color: OTHER_COLOR, key: OTHER, label: "Other" });
|
||||
}
|
||||
const stackOf = (flow: Flow) => {
|
||||
const cat = flow.category?.id ?? NONE;
|
||||
return cat === NONE || top.has(cat) ? cat : OTHER;
|
||||
};
|
||||
return { catKeys, labels, stackOf };
|
||||
}
|
||||
|
||||
export function buildBoard(
|
||||
flows: Flow[],
|
||||
windowMonths: string[],
|
||||
year: string,
|
||||
order: string[],
|
||||
now = today()
|
||||
): Board {
|
||||
const months = buckets(`${year}-01-01`, `${year}-12-31`, "month");
|
||||
const closed = year < now.slice(0, 4) ? `${Number(year) + 1}-01-01` : now;
|
||||
const first = flows.reduce(
|
||||
(min, flow) => (flow.date < min ? flow.date : min),
|
||||
"9999"
|
||||
);
|
||||
const typMonths = typicalMonths(windowMonths, closed, first);
|
||||
const spending = flows.filter((flow) => flow.dir === -1);
|
||||
const spent = series(spending, (flow) => flow.currency);
|
||||
const earned = series(
|
||||
flows.filter((flow) => flow.dir === 1),
|
||||
(flow) => flow.currency
|
||||
);
|
||||
const shown = new Set([...months, ...typMonths]);
|
||||
const { catKeys, labels, stackOf } = rankCategories(
|
||||
spending,
|
||||
new Set(months)
|
||||
);
|
||||
const counts = new Map<string, number>();
|
||||
for (const flow of flows) {
|
||||
if (shown.has(monthOf(flow.date))) {
|
||||
counts.set(flow.currency, (counts.get(flow.currency) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const primary = order[0] ?? "";
|
||||
const rank = (code: string) => (code === primary ? 0 : 1);
|
||||
const codes = [...counts.keys()].sort(
|
||||
(a, b) => rank(a) - rank(b) || (counts.get(b) ?? 0) - (counts.get(a) ?? 0)
|
||||
);
|
||||
return {
|
||||
categories: series(spending, (flow) =>
|
||||
key(flow.currency, keys.category(flow).key)
|
||||
),
|
||||
catKeys,
|
||||
catLabel: labels,
|
||||
codes,
|
||||
earned,
|
||||
months,
|
||||
now,
|
||||
partial: months.includes(monthOf(now)) ? monthOf(now) : null,
|
||||
primary,
|
||||
spending,
|
||||
spent,
|
||||
stackOf,
|
||||
stacks: series(spending, (flow) => key(flow.currency, stackOf(flow))),
|
||||
typical: new Map(
|
||||
codes.map((code) => [code, typical(spent.get(code), typMonths)])
|
||||
),
|
||||
typicalMonths: typMonths,
|
||||
};
|
||||
}
|
||||
|
||||
const QUIET_HEIGHT = 56;
|
||||
|
||||
export function spendingLanes(board: Board): Lane[] {
|
||||
return board.codes.map((code) => ({
|
||||
axisFormat: modeAxis(code),
|
||||
dots: Object.fromEntries(
|
||||
board.months.map((month) => [
|
||||
month,
|
||||
board.earned.get(code)?.get(month) ?? 0,
|
||||
])
|
||||
),
|
||||
format: (value: number) => money(code, value, false, 0),
|
||||
height: board.typical.get(code) ? undefined : QUIET_HEIGHT,
|
||||
key: code,
|
||||
keys: board.catKeys,
|
||||
label: code,
|
||||
rows: board.months.map((month) => ({
|
||||
key: month,
|
||||
label: shortMonth(month),
|
||||
values: Object.fromEntries(
|
||||
board.catKeys.map((k) => [
|
||||
k.key,
|
||||
board.stacks.get(key(code, k.key))?.get(month) ?? 0,
|
||||
])
|
||||
),
|
||||
})),
|
||||
typical: board.typical.get(code) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergedLane(board: Board): Lane {
|
||||
const code = board.primary;
|
||||
const pc = (flow: Flow) => flow.pc ?? 0;
|
||||
const stacks = series(board.spending, board.stackOf, pc);
|
||||
const spent = series(board.spending, () => code, pc).get(code);
|
||||
return {
|
||||
axisFormat: modeAxis(code),
|
||||
format: (value: number) => money(code, value, false, 0),
|
||||
key: "pc",
|
||||
keys: board.catKeys,
|
||||
label: `≈${code}`,
|
||||
rows: board.months.map((month) => ({
|
||||
key: month,
|
||||
label: shortMonth(month),
|
||||
values: Object.fromEntries(
|
||||
board.catKeys.map((k) => [k.key, stacks.get(k.key)?.get(month) ?? 0])
|
||||
),
|
||||
})),
|
||||
typical: typical(spent, board.typicalMonths),
|
||||
};
|
||||
}
|
||||
|
||||
const sumOf = (byMonth: Map<string, number> | undefined, months: string[]) =>
|
||||
months.reduce((sum, month) => sum + (byMonth?.get(month) ?? 0), 0);
|
||||
|
||||
export interface Headline {
|
||||
code: string;
|
||||
earned: number;
|
||||
pace: Pace | null;
|
||||
spent: number;
|
||||
typical: number;
|
||||
}
|
||||
|
||||
export function headlines(board: Board, months = board.months): Headline[] {
|
||||
return board.codes.map((code) => ({
|
||||
code,
|
||||
earned: sumOf(board.earned.get(code), months),
|
||||
pace:
|
||||
board.partial && months.includes(board.partial)
|
||||
? pace(
|
||||
board.spending.filter((flow) => flow.currency === code),
|
||||
board.typicalMonths,
|
||||
board.now
|
||||
)
|
||||
: null,
|
||||
spent: sumOf(board.spent.get(code), months),
|
||||
typical: board.typical.get(code) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface Grew {
|
||||
cat: string;
|
||||
code: string;
|
||||
impact: number;
|
||||
key: string;
|
||||
label: string;
|
||||
month: number;
|
||||
ratio: number | null;
|
||||
typical: number;
|
||||
values: number[];
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function grew(board: Board, month: string): Grew[] {
|
||||
const partial = month === board.partial;
|
||||
const day = Number(board.now.slice(8, 10));
|
||||
const lastMonth = board.partial ?? board.months.at(-1) ?? month;
|
||||
const months = board.months.filter((m) => m <= lastMonth);
|
||||
const byKey = new Map<string, Flow[]>();
|
||||
if (partial) {
|
||||
for (const flow of board.spending) {
|
||||
const k = key(flow.currency, keys.category(flow).key);
|
||||
const list = byKey.get(k) ?? [];
|
||||
list.push(flow);
|
||||
byKey.set(k, list);
|
||||
}
|
||||
}
|
||||
const rows: Grew[] = [];
|
||||
for (const [k, byMonth] of board.categories) {
|
||||
const split = k.indexOf(" ");
|
||||
const code = k.slice(0, split);
|
||||
const cat = k.slice(split + 1);
|
||||
const base = board.typical.get(code) || 1;
|
||||
const now = partial
|
||||
? (byKey.get(k) ?? [])
|
||||
.filter((f) => monthOf(f.date) === month && f.date <= board.now)
|
||||
.reduce((sum, f) => sum + f.amount, 0)
|
||||
: (byMonth.get(month) ?? 0);
|
||||
const typ = partial
|
||||
? typicalToDay(byKey.get(k) ?? [], board.typicalMonths, day)
|
||||
: typical(byMonth, board.typicalMonths);
|
||||
const year = sumOf(byMonth, board.months);
|
||||
if (now === 0 && typ === 0 && year === 0) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
cat,
|
||||
code,
|
||||
impact: (now - typ) / base,
|
||||
key: k,
|
||||
label: board.catLabel.get(cat) ?? cat,
|
||||
month: now,
|
||||
ratio: versus(now, typ),
|
||||
typical: typ,
|
||||
values: months.map((m) => byMonth.get(m) ?? 0),
|
||||
year,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => b.impact - a.impact);
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
count: number;
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MonthFacts {
|
||||
code: string;
|
||||
day: { date: string; value: number; where: string; count: number } | null;
|
||||
earned: number;
|
||||
places: Place[];
|
||||
ratio: number | null;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export function monthFacts(board: Board, month: string): MonthFacts[] {
|
||||
const partial = month === board.partial;
|
||||
return board.codes.map((code) => {
|
||||
const flows = board.spending.filter(
|
||||
(flow) => flow.currency === code && monthOf(flow.date) === month
|
||||
);
|
||||
const places = new Map<string, Place>();
|
||||
const days = new Map<string, Flow[]>();
|
||||
for (const flow of flows) {
|
||||
const place = places.get(flow.other.id) ?? {
|
||||
count: 0,
|
||||
id: flow.other.id,
|
||||
name: flow.other.name,
|
||||
value: 0,
|
||||
};
|
||||
place.count += 1;
|
||||
place.value += flow.amount;
|
||||
places.set(flow.other.id, place);
|
||||
const list = days.get(flow.date) ?? [];
|
||||
list.push(flow);
|
||||
days.set(flow.date, list);
|
||||
}
|
||||
let day: MonthFacts["day"] = null;
|
||||
for (const [date, list] of days) {
|
||||
const value = list.reduce((sum, f) => sum + f.amount, 0);
|
||||
if (!day || value > day.value) {
|
||||
const top = list.reduce((a, b) => (b.amount > a.amount ? b : a));
|
||||
day = { count: list.length, date, value, where: top.other.name };
|
||||
}
|
||||
}
|
||||
const spent = board.spent.get(code)?.get(month) ?? 0;
|
||||
const p = partial
|
||||
? pace(
|
||||
board.spending.filter((flow) => flow.currency === code),
|
||||
board.typicalMonths,
|
||||
board.now
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
code,
|
||||
day,
|
||||
earned: board.earned.get(code)?.get(month) ?? 0,
|
||||
places: [...places.values()]
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, TOP_PLACES),
|
||||
ratio: p
|
||||
? versus(p.sofar, p.typical)
|
||||
: versus(spent, board.typical.get(code) ?? 0),
|
||||
spent,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import { addDays, addMonths, today } from "#lib/dates.js";
|
||||
import { buckets, type Flow, toFlows } from "./flows.js";
|
||||
import { pages } from "./queries.js";
|
||||
|
||||
export const LEDGER_MONTHS = 13;
|
||||
|
||||
export interface LedgerWindow {
|
||||
end: string;
|
||||
months: string[];
|
||||
start: string;
|
||||
}
|
||||
|
||||
export function ledgerWindow(anchor = today()): LedgerWindow {
|
||||
const month = `${anchor.slice(0, 7)}-01`;
|
||||
const start = addMonths(month, 1 - LEDGER_MONTHS);
|
||||
const end = addDays(addMonths(month, 1), -1);
|
||||
return { end, months: buckets(start, end, "month"), start };
|
||||
}
|
||||
|
||||
export function ledgerQuery(anchor = today()) {
|
||||
const { start, end } = ledgerWindow(anchor);
|
||||
return {
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>("/v1/transactions", {
|
||||
end,
|
||||
start,
|
||||
type: "default",
|
||||
}),
|
||||
queryKey: ["ledger", start, end],
|
||||
};
|
||||
}
|
||||
|
||||
export function ownFlows(
|
||||
groups: Item<TransactionGroup>[],
|
||||
accounts: Item<Account>[]
|
||||
): Flow[] {
|
||||
const own = accounts.filter(
|
||||
(a) =>
|
||||
a.attributes.type === "asset" ||
|
||||
a.attributes.type === "liabilities" ||
|
||||
a.attributes.type === "liability"
|
||||
);
|
||||
const currency = new Map(own.map((a) => [a.id, a.attributes.currency_code]));
|
||||
return toFlows(
|
||||
groups,
|
||||
new Set(own.map((a) => a.id)),
|
||||
(id) => currency.get(id) ?? undefined
|
||||
).filter((flow) => flow.type === "withdrawal" || flow.type === "deposit");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Item, TransactionGroup } from "#lib/api/types.js";
|
||||
|
||||
export type Rate = (from: string, to: string) => number | null;
|
||||
|
||||
type Pairs = Map<string, Map<string, [number, number]>>;
|
||||
|
||||
function pairs(groups: Item<TransactionGroup>[]): Pairs {
|
||||
const sums: Pairs = new Map();
|
||||
const add = (a: string, b: string, x: number, y: number) => {
|
||||
const row = sums.get(a) ?? new Map<string, [number, number]>();
|
||||
const pair = row.get(b) ?? [0, 0];
|
||||
row.set(b, [pair[0] + x, pair[1] + y]);
|
||||
sums.set(a, row);
|
||||
};
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions) {
|
||||
const from = split.currency_code;
|
||||
const to = split.foreign_currency_code;
|
||||
const x = Math.abs(Number(split.amount));
|
||||
const y = Math.abs(Number(split.foreign_amount));
|
||||
if (from && to && from !== to && x > 0 && y > 0) {
|
||||
add(from, to, x, y);
|
||||
add(to, from, y, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sums;
|
||||
}
|
||||
|
||||
function walk(sums: Pairs, from: string, to: string): number | null {
|
||||
const rates = new Map([[from, 1]]);
|
||||
const queue = [from];
|
||||
for (const code of queue) {
|
||||
const rate = rates.get(code) ?? 1;
|
||||
for (const [other, [x, y]] of sums.get(code) ?? []) {
|
||||
if (!rates.has(other)) {
|
||||
rates.set(other, (rate * y) / x);
|
||||
queue.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rates.get(to) ?? null;
|
||||
}
|
||||
|
||||
export function impliedRates(groups: Item<TransactionGroup>[]): Rate {
|
||||
const sums = pairs(groups);
|
||||
return (from, to) => walk(sums, from, to);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import { formatMoney } from "#lib/format.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -136,6 +136,7 @@ export interface Drill {
|
||||
asset?: string;
|
||||
at?: string;
|
||||
box: string;
|
||||
currency?: string;
|
||||
dim?: Dimension | "day" | "flow";
|
||||
dir?: 1 | -1;
|
||||
key?: string;
|
||||
@@ -172,6 +173,7 @@ function readDrill(url: {
|
||||
asset: url.searchParams.get("acc") ?? undefined,
|
||||
at: url.searchParams.get("at") ?? undefined,
|
||||
box,
|
||||
currency: url.searchParams.get("dc") ?? undefined,
|
||||
dim: DRILL_DIMS.has(dim) ? (dim as Drill["dim"]) : undefined,
|
||||
dir: DIRS[dir ?? ""],
|
||||
key: split > 0 ? raw.slice(split + 1) : undefined,
|
||||
@@ -181,12 +183,21 @@ function readDrill(url: {
|
||||
|
||||
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: null,
|
||||
at: null,
|
||||
box: null,
|
||||
dc: null,
|
||||
dir: null,
|
||||
dl: null,
|
||||
drill: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
acc: drill.asset ?? null,
|
||||
at: drill.at ?? null,
|
||||
box: drill.box,
|
||||
dc: drill.currency ?? null,
|
||||
dir: drill.dir ? DIR_NAMES[drill.dir] : null,
|
||||
dl: drill.label || null,
|
||||
drill:
|
||||
@@ -208,6 +219,9 @@ export function drillFilter(
|
||||
if (drill.asset && flow.asset.id !== drill.asset) {
|
||||
return false;
|
||||
}
|
||||
if (drill.currency && flow.currency !== drill.currency) {
|
||||
return false;
|
||||
}
|
||||
if (!drill.dim || drill.key === undefined) {
|
||||
return true;
|
||||
}
|
||||
@@ -246,7 +260,15 @@ function twins(
|
||||
);
|
||||
}
|
||||
|
||||
export function createReport(params: () => ReportParams) {
|
||||
const inRange = (group: Item<TransactionGroup>, start: string, end: string) => {
|
||||
const date = group.attributes.transactions[0]?.date.slice(0, 10) ?? "";
|
||||
return date >= start && date <= end;
|
||||
};
|
||||
|
||||
export function createReport(
|
||||
params: () => ReportParams,
|
||||
source?: () => Item<TransactionGroup>[] | undefined
|
||||
) {
|
||||
const p = $derived(params());
|
||||
const kind = $derived(kindOf(p.type));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
@@ -273,8 +295,13 @@ export function createReport(params: () => ReportParams) {
|
||||
}));
|
||||
const tx = createQuery(() => ({
|
||||
...reportQueries.transactions(p.start, p.end),
|
||||
enabled: p.type !== "audit",
|
||||
enabled: !source && p.type !== "audit",
|
||||
}));
|
||||
const groups = $derived(
|
||||
source
|
||||
? source()?.filter((group) => inRange(group, p.start, p.end))
|
||||
: tx.data
|
||||
);
|
||||
|
||||
const accounts = $derived([
|
||||
...(assets.data ?? []),
|
||||
@@ -347,14 +374,14 @@ export function createReport(params: () => ReportParams) {
|
||||
);
|
||||
|
||||
const flows = $derived(
|
||||
tx.data && accountsReady
|
||||
? toFlows(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
groups && accountsReady
|
||||
? toFlows(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const swaps = $derived(
|
||||
tx.data && accountsReady
|
||||
? exchanges(tx.data, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
groups && accountsReady
|
||||
? exchanges(groups, selectedSet, (id) => byId.get(id)?.currency_code)
|
||||
: []
|
||||
);
|
||||
|
||||
@@ -451,7 +478,7 @@ export function createReport(params: () => ReportParams) {
|
||||
return grain;
|
||||
},
|
||||
get groups() {
|
||||
return tx.data;
|
||||
return groups;
|
||||
},
|
||||
itemName: (kindKey: string, id: string) =>
|
||||
itemNames.get(`${kindKey}:${id}`),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { today } from "#lib/dates.js";
|
||||
import { percent } from "#lib/format.js";
|
||||
import type { Flow } from "./flows.js";
|
||||
|
||||
export const TYPICAL_MONTHS = 12;
|
||||
const SAME = 0.03;
|
||||
|
||||
export function median(values: number[]): number {
|
||||
if (values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
export function typicalMonths(
|
||||
months: string[],
|
||||
now = today(),
|
||||
since = ""
|
||||
): string[] {
|
||||
const current = now.slice(0, 7);
|
||||
const first = since.slice(0, 7);
|
||||
return months
|
||||
.filter((month) => month < current && month >= first)
|
||||
.slice(-TYPICAL_MONTHS);
|
||||
}
|
||||
|
||||
export function series(
|
||||
flows: Iterable<Flow>,
|
||||
keyOf: (flow: Flow) => string | null,
|
||||
measure: (flow: Flow) => number = (flow) => flow.amount
|
||||
): Map<string, Map<string, number>> {
|
||||
const result = new Map<string, Map<string, number>>();
|
||||
for (const flow of flows) {
|
||||
const key = keyOf(flow);
|
||||
if (key === null) {
|
||||
continue;
|
||||
}
|
||||
let months = result.get(key);
|
||||
if (!months) {
|
||||
months = new Map();
|
||||
result.set(key, months);
|
||||
}
|
||||
const month = flow.date.slice(0, 7);
|
||||
months.set(month, (months.get(month) ?? 0) + measure(flow));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function typical(
|
||||
byMonth: ReadonlyMap<string, number> | undefined,
|
||||
months: string[]
|
||||
): number {
|
||||
return median(months.map((month) => byMonth?.get(month) ?? 0));
|
||||
}
|
||||
|
||||
export function typicalToDay(
|
||||
flows: Iterable<Flow>,
|
||||
months: string[],
|
||||
day: number
|
||||
): number {
|
||||
const sums = new Map(months.map((month) => [month, 0]));
|
||||
for (const flow of flows) {
|
||||
const month = flow.date.slice(0, 7);
|
||||
const sum = sums.get(month);
|
||||
if (sum !== undefined && Number(flow.date.slice(8, 10)) <= day) {
|
||||
sums.set(month, sum + flow.amount);
|
||||
}
|
||||
}
|
||||
return median([...sums.values()]);
|
||||
}
|
||||
|
||||
export interface Pace {
|
||||
day: number;
|
||||
month: string;
|
||||
sofar: number;
|
||||
typical: number;
|
||||
}
|
||||
|
||||
export function pace(
|
||||
flows: Iterable<Flow>,
|
||||
months: string[],
|
||||
now = today()
|
||||
): Pace {
|
||||
const month = now.slice(0, 7);
|
||||
const day = Number(now.slice(8, 10));
|
||||
let sofar = 0;
|
||||
const past: Flow[] = [];
|
||||
for (const flow of flows) {
|
||||
if (flow.date.slice(0, 7) === month) {
|
||||
sofar += flow.date <= now ? flow.amount : 0;
|
||||
} else {
|
||||
past.push(flow);
|
||||
}
|
||||
}
|
||||
return { day, month, sofar, typical: typicalToDay(past, months, day) };
|
||||
}
|
||||
|
||||
export function versus(value: number, base: number): number | null {
|
||||
return base > 0 ? (value - base) / base : null;
|
||||
}
|
||||
|
||||
export function versusText(ratio: number | null, noun = "typical"): string {
|
||||
if (ratio === null) {
|
||||
return "";
|
||||
}
|
||||
if (Math.abs(ratio) < SAME) {
|
||||
return noun ? `about ${noun}` : "about the same";
|
||||
}
|
||||
const side = ratio > 0 ? "above" : "below";
|
||||
return `${percent(Math.abs(ratio))} ${side}${noun ? ` ${noun}` : ""}`;
|
||||
}
|
||||
Reference in new issue
Block a user