From 71e8fc948ba51d91580ce77849606617ff49c993 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:27:23 +0200 Subject: [PATCH] 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) --- src/export/ods.ts | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/export/ods.ts b/src/export/ods.ts index fb6e5e6..4bb0ffe 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -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): 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>; + 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)), ]; }