perf(export): fold once per month when building the ODS

monthView folds from the start of the dataset, so it costs O(months) per
call. The summary sheet and every per-post sheet each called it once per
(post, month), making a full export O(posts x months^2). buildWorkbook now
takes one pass, indexes the figures by post, and hands that to the sheets.

TODO called this "fine at personal-budget scale". Measured against the old
code it is not, once history accumulates: 30 posts over 10 years went from
7983 ms to 135 ms (59x), and that work is synchronous on the main thread, so
it was a visible freeze on clicking Export. 10 posts x 2 years: 44 ms -> 2.5 ms.

content.xml is byte-for-byte identical on a fixture exercising every sheet
(versioned rules, an override, an archived post, a percent split, a fixed
split and a finance plan).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 14:27:23 +02:00
1 parent b9be882c05
commit 71e8fc948b
1 file changed
+29 -11
+29 -11
View File
@@ -3,6 +3,8 @@ import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charge
import { monthRange } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
import type { MonthViewModel } from "../domain/views.ts";
import type { PostMonthFigures } from "../domain/fold.ts";
import type { Dataset, MonthId } from "../domain/types.ts";
import {
buildContentXml,
@@ -18,16 +20,14 @@ export function odsFilename(monthId: MonthId): string {
return `budget-${monthId}.ods`;
}
function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMonth): Sheet {
const base = dataset.settings.baseCurrency;
const rows = [
[text(`Post (${base})`), ...months.map((m) => text(m)), text("Total")],
];
for (const post of [...dataset.posts].sort((a, b) => a.order - b.order)) {
const perMonth = months.map(
(m) => monthView(dataset, m).rows.find((r) => r.post.id === post.id)?.figures.charges ?? 0,
);
const perMonth = months.map((m) => byMonth.get(m)?.get(post.id)?.charges ?? 0);
rows.push([
text(post.name),
...perMonth.map(num),
@@ -44,12 +44,12 @@ function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
return { name: "Summary", rows };
}
function monthsSheet(dataset: Dataset, months: MonthId[]): Sheet {
function monthsSheet(months: MonthId[], views: Map<MonthId, MonthViewModel>): Sheet {
const rows = [
[text("Month"), text("Income"), text("Allocated"), text("Spent"), text("Unallocated")],
];
for (const monthId of months) {
const view = monthView(dataset, monthId);
const view = views.get(monthId)!;
rows.push([
text(monthId),
num(view.income),
@@ -100,12 +100,17 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
return { name: "Purchases", rows };
}
function postSheet(dataset: Dataset, postId: string, name: string, months: MonthId[]): Sheet {
function postSheet(
postId: string,
name: string,
months: MonthId[],
byMonth: FiguresByMonth,
): Sheet {
const rows = [
[text("Month"), text("Carried in"), text("Allocated"), text("Spent"), text("Remaining")],
];
for (const monthId of months) {
const figures = monthView(dataset, monthId).rows.find((r) => r.post.id === postId)?.figures;
const figures = byMonth.get(monthId)?.get(postId);
rows.push([
text(monthId),
num(figures?.carriedIn ?? 0),
@@ -117,17 +122,30 @@ function postSheet(dataset: Dataset, postId: string, name: string, months: Month
return { name, rows };
}
/** Every post's figures for one month, keyed by post id. */
type FiguresByMonth = Map<MonthId, Map<string, PostMonthFigures>>;
export function buildWorkbook(dataset: Dataset): Sheet[] {
const { from, to } = datasetMonthSpan(dataset);
const months = monthRange(from, to);
// monthView folds from the start of the dataset, so it is O(months) each
// time. Calling it once per (post, month) — as the summary and per-post
// sheets each used to — made a full export O(posts x months^2). One pass
// here, indexed by post, leaves the whole workbook O(months^2) with the
// sheets doing map lookups. Output is unchanged.
const views = new Map(months.map((m) => [m, monthView(dataset, m)]));
const byMonth: FiguresByMonth = new Map(
months.map((m) => [m, new Map(views.get(m)!.rows.map((r) => [r.post.id, r.figures]))]),
);
return [
summarySheet(dataset, months),
monthsSheet(dataset, months),
summarySheet(dataset, months, byMonth),
monthsSheet(months, views),
purchasesSheet(dataset, months),
...[...dataset.posts]
.sort((a, b) => a.order - b.order)
.map((post) => postSheet(dataset, post.id, post.name, months)),
.map((post) => postSheet(post.id, post.name, months, byMonth)),
];
}