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:
1 parent
b9be882c05
commit
71e8fc948b
1 file changed
+29
-11
+29
-11
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user