feat: one page for a category, tag, budget or merchant
The page answers how much and whether that is normal: this month per currency with count, median and pace against a typical month, thirteen months as lanes per currency, and what is inside (merchants, cards, budgets, tags, subtags) on 1 to 4 with Enter narrowing the transactions below. Budgets keep spent, left and overspent with the limit line; expense and revenue accounts use the same page with visits and cadence. The twelve period cards and per-day charts are gone.
This commit is contained in:
1 parent
349a6a0b32
commit
722fdc2a20
19 files changed
+1667
-794
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import { type CreateQueryResult, createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import { contains, type Range, today } from "#lib/dates.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { monthOf } from "#lib/reports/board.js";
|
||||
import { buckets, type Flow } from "#lib/reports/flows.js";
|
||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||
import { pages } from "#lib/reports/queries.js";
|
||||
import { impliedRates } from "#lib/reports/rates.js";
|
||||
import { typicalMonths } from "#lib/reports/typical.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import type { Group } from "#lib/transactions/split.js";
|
||||
import type { Spec } from "./spec.js";
|
||||
import { codesOf, dirOf, factsOf, normsOf, weigh } from "./stats.js";
|
||||
|
||||
export interface Args {
|
||||
allTime?: boolean;
|
||||
everything?: boolean;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
interface Kept {
|
||||
groups: Group[];
|
||||
journals: Set<string>;
|
||||
}
|
||||
|
||||
function keptOf(
|
||||
groups: Group[],
|
||||
keep: (split: Split) => boolean,
|
||||
range: Range | null
|
||||
): Kept {
|
||||
const journals = new Set<string>();
|
||||
const kept: Group[] = [];
|
||||
for (const group of groups) {
|
||||
let hit = false;
|
||||
for (const split of group.attributes.transactions) {
|
||||
if (keep(split) && (!range || contains(range, split.date.slice(0, 10)))) {
|
||||
journals.add(String(split.transaction_journal_id));
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
kept.push(group);
|
||||
}
|
||||
}
|
||||
return { groups: kept, journals };
|
||||
}
|
||||
|
||||
const flowsOf = (kept: Kept, accounts: Item<Account>[]): Flow[] =>
|
||||
ownFlows(kept.groups, accounts).filter((flow) =>
|
||||
kept.journals.has(flow.journal)
|
||||
);
|
||||
|
||||
export class DimensionModel {
|
||||
readonly now = today();
|
||||
readonly args: () => Args;
|
||||
readonly ledger: CreateQueryResult<Group[]>;
|
||||
readonly accounts: CreateQueryResult<Item<Account>[]>;
|
||||
readonly source: CreateQueryResult<Group[]>;
|
||||
|
||||
spec = $derived.by(() => this.args().spec);
|
||||
everything = $derived.by(() => Boolean(this.args().everything));
|
||||
allTime = $derived.by(() => this.everything || Boolean(this.args().allTime));
|
||||
range = $derived<Range | null>(this.everything ? null : period.range);
|
||||
anchor = $derived(
|
||||
this.range && this.range.end < ledgerWindow(this.now).start
|
||||
? this.range.end
|
||||
: this.now
|
||||
);
|
||||
window = $derived(ledgerWindow(this.anchor));
|
||||
inside = $derived(
|
||||
this.range !== null &&
|
||||
this.range.start >= this.window.start &&
|
||||
this.range.end <= this.window.end
|
||||
);
|
||||
sourceRange = $derived(this.allTime ? null : this.range);
|
||||
|
||||
raw = $derived.by(
|
||||
() =>
|
||||
(this.allTime ? this.source.data : this.ledger.data) ?? ([] as Group[])
|
||||
);
|
||||
loading = $derived.by(
|
||||
() =>
|
||||
this.accounts.isPending ||
|
||||
(this.allTime ? this.source.isPending : this.ledger.isPending) ||
|
||||
(!(this.allTime || this.inside) && this.source.isPending)
|
||||
);
|
||||
base = $derived(keptOf(this.raw, this.spec.keep, null));
|
||||
inPeriod = $derived.by(() => {
|
||||
if (this.everything) {
|
||||
return this.base;
|
||||
}
|
||||
const from =
|
||||
this.allTime || this.inside ? this.raw : (this.source.data ?? []);
|
||||
return keptOf(from, this.spec.keep, this.range);
|
||||
});
|
||||
groups = $derived(this.inPeriod.groups);
|
||||
flows = $derived.by(() => flowsOf(this.base, this.accounts.data ?? []));
|
||||
periodFlows = $derived.by(() =>
|
||||
flowsOf(this.inPeriod, this.accounts.data ?? [])
|
||||
);
|
||||
dir = $derived(dirOf(this.flows.length > 0 ? this.flows : this.periodFlows));
|
||||
main = $derived(this.flows.filter((flow) => flow.dir === this.dir));
|
||||
periodMain = $derived(
|
||||
this.periodFlows.filter((flow) => flow.dir === this.dir)
|
||||
);
|
||||
codes = $derived(codesOf([...this.main, ...this.periodMain]));
|
||||
history = $derived(factsOf(this.main, this.codes));
|
||||
months = $derived(
|
||||
this.everything && this.history.first
|
||||
? buckets(this.history.first, this.now, "month")
|
||||
: this.window.months
|
||||
);
|
||||
typicalMonths = $derived(
|
||||
typicalMonths(this.months, this.now, period.first ?? this.history.first)
|
||||
);
|
||||
focus = $derived(
|
||||
this.range && monthOf(this.range.start) === monthOf(this.range.end)
|
||||
? monthOf(this.range.start)
|
||||
: null
|
||||
);
|
||||
partial = $derived(
|
||||
this.months.includes(monthOf(this.now)) ? monthOf(this.now) : null
|
||||
);
|
||||
norms = $derived(
|
||||
normsOf(this.main, this.codes, this.typicalMonths, this.focus, this.now)
|
||||
);
|
||||
facts = $derived(factsOf(this.periodMain, this.codes));
|
||||
weight = $derived(
|
||||
weigh(
|
||||
[...this.main, ...this.periodMain],
|
||||
session.primary?.code ?? "",
|
||||
impliedRates(this.raw)
|
||||
)
|
||||
);
|
||||
|
||||
constructor(args: () => Args) {
|
||||
this.args = args;
|
||||
this.ledger = createQuery(() => ({
|
||||
...ledgerQuery(this.anchor),
|
||||
enabled: !this.allTime,
|
||||
}));
|
||||
this.accounts = createQuery(() => lists.accounts("all"));
|
||||
this.source = createQuery(() => {
|
||||
const range = this.sourceRange;
|
||||
return {
|
||||
enabled: this.allTime || !this.inside,
|
||||
queryFn: () =>
|
||||
pages<TransactionGroup>(this.spec.source, {
|
||||
...(range ?? {}),
|
||||
type: "default",
|
||||
}),
|
||||
queryKey: [
|
||||
"dimension",
|
||||
this.spec.source,
|
||||
range?.start ?? "all",
|
||||
range?.end ?? "all",
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Item, Split, Tag } from "#lib/api/types.js";
|
||||
import { buildTree, flatten } from "#lib/components/tags/tree.js";
|
||||
import { type Flow, type Key, keys, NONE } from "#lib/reports/flows.js";
|
||||
|
||||
export interface Facet {
|
||||
id: string;
|
||||
incomeLabel?: string;
|
||||
label: string;
|
||||
of: (flow: Flow) => Key[];
|
||||
}
|
||||
|
||||
export interface Spec {
|
||||
facets: Facet[];
|
||||
keep: (split: Split) => boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const listOf = (pick: (flow: Flow) => Key | null) => (flow: Flow) => {
|
||||
const key = pick(flow);
|
||||
return key ? [key] : [];
|
||||
};
|
||||
|
||||
const facets = {
|
||||
budgets: { id: "budgets", label: "Budgets", of: listOf(keys.budget) },
|
||||
cards: { id: "cards", label: "Cards", of: listOf(keys.asset) },
|
||||
categories: {
|
||||
id: "categories",
|
||||
label: "Categories",
|
||||
of: listOf(keys.category),
|
||||
},
|
||||
merchants: {
|
||||
id: "merchants",
|
||||
incomeLabel: "Payers",
|
||||
label: "Merchants",
|
||||
of: listOf(keys.other),
|
||||
},
|
||||
tags: { id: "tags", label: "Tags", of: keys.tag },
|
||||
} satisfies Record<string, Facet>;
|
||||
|
||||
export const categorySpec = (id: string): Spec => ({
|
||||
facets: [facets.merchants, facets.cards, facets.budgets, facets.tags],
|
||||
keep: (split) => String(split.category_id ?? "") === id,
|
||||
source: `/v1/categories/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const budgetSpec = (id: string): Spec => ({
|
||||
facets: [facets.merchants, facets.cards, facets.categories, facets.tags],
|
||||
keep: (split) => String(split.budget_id ?? "") === id,
|
||||
source: `/v1/budgets/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const merchantSpec = (id: string): Spec => ({
|
||||
facets: [facets.categories, facets.cards, facets.budgets, facets.tags],
|
||||
keep: (split) =>
|
||||
String(split.source_id) === id || String(split.destination_id) === id,
|
||||
source: `/v1/accounts/${id}/transactions`,
|
||||
});
|
||||
|
||||
export const underTag = (name: string, tag: string) =>
|
||||
tag === name || tag.startsWith(`${name}/`);
|
||||
|
||||
function subtagFacet(name: string, tags: Item<Tag>[]): Facet | null {
|
||||
const node = flatten(buildTree(tags)).find((n) => n.path === name);
|
||||
const children = node?.children ?? [];
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const childOf = (tag: string): Key | null => {
|
||||
if (tag === name) {
|
||||
return { key: NONE, label: "(no subtag)" };
|
||||
}
|
||||
const child = children.find((c) => underTag(c.path, tag));
|
||||
return child
|
||||
? {
|
||||
href: `/tags/show/${encodeURIComponent(child.path)}`,
|
||||
key: child.path,
|
||||
label: child.name,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
return {
|
||||
id: "subtags",
|
||||
label: "Subtags",
|
||||
of: (flow) => {
|
||||
const found = new Map<string, Key>();
|
||||
for (const tag of flow.tags) {
|
||||
const key = childOf(tag);
|
||||
if (key) {
|
||||
found.set(key.key, key);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function tagSpec(name: string, tags: Item<Tag>[]): Spec {
|
||||
const subtags = subtagFacet(name, tags);
|
||||
return {
|
||||
facets: [
|
||||
...(subtags ? [subtags] : []),
|
||||
facets.merchants,
|
||||
facets.cards,
|
||||
facets.categories,
|
||||
],
|
||||
keep: (split) => (split.tags ?? []).some((tag) => underTag(name, tag)),
|
||||
source: "/v1/transactions",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { BudgetLimit, Item } from "#lib/api/types.js";
|
||||
import {
|
||||
type Lane,
|
||||
type StackKey,
|
||||
tones,
|
||||
wash,
|
||||
} from "#lib/components/charts/chart.js";
|
||||
import { contains, daysBetween, type Range } from "#lib/dates.js";
|
||||
import { monthOf, shortMonth } from "#lib/reports/board.js";
|
||||
import { type Flow, group, type Totals } from "#lib/reports/flows.js";
|
||||
import type { Rate } from "#lib/reports/rates.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
import { median, pace, series, typical, versus } from "#lib/reports/typical.js";
|
||||
import { modeAxis } from "#lib/reports/view.js";
|
||||
import type { Facet } from "./spec.js";
|
||||
|
||||
export type Dir = 1 | -1;
|
||||
|
||||
export interface Money {
|
||||
amount: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface Facts {
|
||||
count: number;
|
||||
first: string;
|
||||
last: string;
|
||||
median: Money | null;
|
||||
sums: Money[];
|
||||
}
|
||||
|
||||
export interface Norm {
|
||||
code: string;
|
||||
projected: number | null;
|
||||
ratio: number | null;
|
||||
typical: number;
|
||||
}
|
||||
|
||||
export interface FacetRow {
|
||||
href?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
share: number;
|
||||
spark: number[];
|
||||
sums: Money[];
|
||||
}
|
||||
|
||||
export interface LimitFact {
|
||||
amount: number;
|
||||
code: string;
|
||||
day: number | null;
|
||||
days: number;
|
||||
id: string;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface StripCell {
|
||||
amount: number;
|
||||
id: string | null;
|
||||
month: string;
|
||||
spent: number;
|
||||
}
|
||||
|
||||
const BRAND = "var(--ff-brand)";
|
||||
const PLURAL = /s$/;
|
||||
const LEFT_WASH = 28;
|
||||
|
||||
export const counted = (count: number, noun: string) =>
|
||||
`${count} ${count === 1 ? noun.replace(PLURAL, "") : noun}`;
|
||||
|
||||
export const fmt = (code: string, amount: number, decimals?: number) =>
|
||||
money(code, amount, false, decimals);
|
||||
|
||||
export function dirOf(flows: Flow[]): Dir {
|
||||
const out = flows.filter((flow) => flow.dir === -1).length;
|
||||
return out * 2 >= flows.length ? -1 : 1;
|
||||
}
|
||||
|
||||
function tally(flows: Flow[]) {
|
||||
const by = new Map<string, { count: number; sum: number }>();
|
||||
for (const flow of flows) {
|
||||
const t = by.get(flow.currency) ?? { count: 0, sum: 0 };
|
||||
t.count += 1;
|
||||
t.sum += flow.amount;
|
||||
by.set(flow.currency, t);
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
export function codesOf(flows: Flow[]): string[] {
|
||||
const by = tally(flows);
|
||||
return [...by.keys()].sort(
|
||||
(a, b) => (by.get(b)?.count ?? 0) - (by.get(a)?.count ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function sumsOf(flows: Flow[], codes: string[]): Money[] {
|
||||
const by = tally(flows);
|
||||
return codes
|
||||
.filter((code) => by.has(code))
|
||||
.map((code) => ({ amount: by.get(code)?.sum ?? 0, code }));
|
||||
}
|
||||
|
||||
export function factsOf(flows: Flow[], codes: string[]): Facts {
|
||||
const main = codes.find((code) => flows.some((f) => f.currency === code));
|
||||
let first = "";
|
||||
let last = "";
|
||||
for (const flow of flows) {
|
||||
first = !first || flow.date < first ? flow.date : first;
|
||||
last = flow.date > last ? flow.date : last;
|
||||
}
|
||||
const amounts = flows.filter((f) => f.currency === main).map((f) => f.amount);
|
||||
return {
|
||||
count: new Set(flows.map((flow) => flow.group)).size,
|
||||
first,
|
||||
last,
|
||||
median: main ? { amount: median(amounts), code: main } : null,
|
||||
sums: sumsOf(flows, codes),
|
||||
};
|
||||
}
|
||||
|
||||
export function normsOf(
|
||||
flows: Flow[],
|
||||
codes: string[],
|
||||
months: string[],
|
||||
focus: string | null,
|
||||
now: string
|
||||
): Norm[] {
|
||||
const by = series(flows, (flow) => flow.currency);
|
||||
return codes.map((code) => {
|
||||
const typ = typical(by.get(code), months);
|
||||
if (focus === monthOf(now)) {
|
||||
const p = pace(
|
||||
flows.filter((flow) => flow.currency === code),
|
||||
months,
|
||||
now
|
||||
);
|
||||
const projected = p.sofar + typ - p.typical;
|
||||
return { code, projected, ratio: versus(projected, typ), typical: typ };
|
||||
}
|
||||
const value = focus ? (by.get(code)?.get(focus) ?? 0) : 0;
|
||||
return {
|
||||
code,
|
||||
projected: null,
|
||||
ratio: focus ? versus(value, typ) : null,
|
||||
typical: typ,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function lanesOf(
|
||||
flows: Flow[],
|
||||
months: string[],
|
||||
codes: string[],
|
||||
dir: Dir,
|
||||
norms: Norm[],
|
||||
height: number
|
||||
): Lane[] {
|
||||
const key: StackKey = {
|
||||
color: BRAND,
|
||||
key: "value",
|
||||
label: dir === -1 ? "Spent" : "Earned",
|
||||
};
|
||||
const main = series(
|
||||
flows.filter((flow) => flow.dir === dir),
|
||||
(flow) => flow.currency
|
||||
);
|
||||
const back = series(
|
||||
flows.filter((flow) => flow.dir !== dir),
|
||||
(flow) => flow.currency
|
||||
);
|
||||
return codes.map((code) => ({
|
||||
axisFormat: modeAxis(code),
|
||||
dots: back.has(code)
|
||||
? Object.fromEntries(
|
||||
months.map((month) => [month, back.get(code)?.get(month) ?? 0])
|
||||
)
|
||||
: undefined,
|
||||
format: (value: number) => fmt(code, value, 0),
|
||||
height,
|
||||
key: code,
|
||||
keys: [key],
|
||||
label: code,
|
||||
rows: months.map((month) => ({
|
||||
key: month,
|
||||
label: shortMonth(month),
|
||||
values: { value: main.get(code)?.get(month) ?? 0 },
|
||||
})),
|
||||
typical: norms.find((n) => n.code === code)?.typical || null,
|
||||
}));
|
||||
}
|
||||
|
||||
const LIMIT_KEYS: StackKey[] = [
|
||||
{ color: BRAND, key: "spent", label: "Spent" },
|
||||
{ color: wash(BRAND, LEFT_WASH), key: "left", label: "Left" },
|
||||
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||
];
|
||||
|
||||
const limitKey = (code: string, month: string) => `${code} ${month}`;
|
||||
|
||||
function limitsByMonth(limits: Item<BudgetLimit>[]) {
|
||||
const by = new Map<string, number>();
|
||||
for (const limit of limits) {
|
||||
const k = limitKey(
|
||||
limit.attributes.currency_code ?? "",
|
||||
monthOf(limit.attributes.start ?? "")
|
||||
);
|
||||
by.set(k, (by.get(k) ?? 0) + Number(limit.attributes.amount));
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
export function withLimits(lanes: Lane[], limits: Item<BudgetLimit>[]): Lane[] {
|
||||
const by = limitsByMonth(limits);
|
||||
return lanes.map((lane) => ({
|
||||
...lane,
|
||||
keys: LIMIT_KEYS,
|
||||
rows: lane.rows.map((row) => {
|
||||
const spent = row.values.value ?? 0;
|
||||
const limit = by.get(limitKey(lane.key, row.key));
|
||||
const values: Record<string, number> = limit
|
||||
? {
|
||||
left: Math.max(0, limit - spent),
|
||||
over: Math.max(0, spent - limit),
|
||||
spent: Math.min(spent, limit),
|
||||
}
|
||||
: { spent };
|
||||
return { ...row, values };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
const limitRangeOf = (limit: Item<BudgetLimit>): Range => ({
|
||||
end: (limit.attributes.end ?? "").slice(0, 10),
|
||||
start: (limit.attributes.start ?? "").slice(0, 10),
|
||||
});
|
||||
|
||||
const spentIn = (flows: Flow[], code: string, range: Range) =>
|
||||
flows
|
||||
.filter((flow) => flow.currency === code && contains(range, flow.date))
|
||||
.reduce((sum, flow) => sum + flow.amount, 0);
|
||||
|
||||
export function limitFacts(
|
||||
limits: Item<BudgetLimit>[],
|
||||
flows: Flow[],
|
||||
range: Range,
|
||||
now: string
|
||||
): LimitFact[] {
|
||||
return limits
|
||||
.filter((limit) => {
|
||||
const r = limitRangeOf(limit);
|
||||
return r.start <= range.end && r.end >= range.start;
|
||||
})
|
||||
.map((limit) => {
|
||||
const r = limitRangeOf(limit);
|
||||
const code = limit.attributes.currency_code ?? "";
|
||||
return {
|
||||
amount: Number(limit.attributes.amount),
|
||||
code,
|
||||
day: contains(r, now) ? daysBetween(r.start, now) + 1 : null,
|
||||
days: daysBetween(r.start, r.end) + 1,
|
||||
id: limit.id,
|
||||
spent: spentIn(flows, code, r),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function stripsOf(
|
||||
limits: Item<BudgetLimit>[],
|
||||
flows: Flow[],
|
||||
months: string[]
|
||||
): Map<string, StripCell[]> {
|
||||
const spent = series(flows, (flow) => flow.currency);
|
||||
const strips = new Map<string, StripCell[]>();
|
||||
const codes = new Set(limits.map((l) => l.attributes.currency_code ?? ""));
|
||||
for (const code of codes) {
|
||||
const own = limits.filter((l) => l.attributes.currency_code === code);
|
||||
const cells = months.map((month) => {
|
||||
const limit = own.find(
|
||||
(l) => monthOf(l.attributes.start ?? "") === month
|
||||
);
|
||||
return {
|
||||
amount: limit ? Number(limit.attributes.amount) : 0,
|
||||
id: limit?.id ?? null,
|
||||
month,
|
||||
spent: spent.get(code)?.get(month) ?? 0,
|
||||
};
|
||||
});
|
||||
if (cells.some((cell) => cell.id)) {
|
||||
strips.set(code, cells);
|
||||
}
|
||||
}
|
||||
return strips;
|
||||
}
|
||||
|
||||
export function weigh(
|
||||
flows: Flow[],
|
||||
primary: string,
|
||||
rate: Rate
|
||||
): (code: string) => number {
|
||||
const codes = [...new Set(flows.map((flow) => flow.currency))];
|
||||
const rates = new Map(
|
||||
codes.map((code) => [code, code === primary ? 1 : rate(code, primary)])
|
||||
);
|
||||
if ([...rates.values()].every((r) => r !== null)) {
|
||||
return (code) => rates.get(code) ?? 0;
|
||||
}
|
||||
const by = tally(flows);
|
||||
return (code) => {
|
||||
const t = by.get(code);
|
||||
return t && t.sum > 0 ? t.count / t.sum : 0;
|
||||
};
|
||||
}
|
||||
|
||||
export function facetRows(
|
||||
facet: Facet,
|
||||
period: Flow[],
|
||||
window: Flow[],
|
||||
months: string[],
|
||||
codes: string[],
|
||||
weight: (code: string) => number
|
||||
): FacetRow[] {
|
||||
const worth = (totals: Totals | undefined) => {
|
||||
let sum = 0;
|
||||
for (const [code, m] of totals?.cur ?? []) {
|
||||
sum += (m.in + m.out) * weight(code);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const sparks = new Map(
|
||||
group(window, facet.of, (flow) => monthOf(flow.date)).map((row) => [
|
||||
row.key,
|
||||
months.map((month) => worth(row.cells.get(month))),
|
||||
])
|
||||
);
|
||||
const total =
|
||||
period.reduce(
|
||||
(sum, flow) => sum + flow.amount * weight(flow.currency),
|
||||
0
|
||||
) || 1;
|
||||
return group(period, facet.of)
|
||||
.map((row) => ({
|
||||
href: row.href,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
share: worth(row.totals) / total,
|
||||
spark: sparks.get(row.key) ?? [],
|
||||
sums: codes
|
||||
.filter((code) => row.totals.cur.has(code))
|
||||
.map((code) => {
|
||||
const m = row.totals.cur.get(code);
|
||||
return { amount: (m?.in ?? 0) + (m?.out ?? 0), code };
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
|
||||
}
|
||||
Reference in new issue
Block a user