Files
budget2.0/src/export/ods.ts
T
Lucas WintherandClaude Opus 5 71e8fc948b 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>
2026-09-02 14:27:23 +02:00

165 lines
5.2 KiB
TypeScript

import { zipSync, strToU8 } from "fflate";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
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,
buildManifestXml,
buildStylesXml,
num,
text,
ODS_MIMETYPE,
type Sheet,
} from "./odsXml.ts";
export function odsFilename(monthId: MonthId): string {
return `budget-${monthId}.ods`;
}
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) => byMonth.get(m)?.get(post.id)?.charges ?? 0);
rows.push([
text(post.name),
...perMonth.map(num),
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), base)),
]);
}
const summary = summaryView(dataset, months[0]!, months[months.length - 1]!);
rows.push([
text("Total"),
...summary.byMonth.map((m) => num(m.charges)),
num(summary.totalCharges),
]);
return { name: "Summary", rows };
}
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 = views.get(monthId)!;
rows.push([
text(monthId),
num(view.income),
num(view.totalAllocation),
num(view.totalCharges),
num(view.unallocated),
]);
}
return { name: "Months", rows };
}
/** One row per split-slice, so the sheet pivots cleanly. */
function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
const base = dataset.settings.baseCurrency;
const postName = new Map(dataset.posts.map((p) => [p.id, p.name]));
const rows = [
[
text("Month"),
text("Date"),
text("Description"),
text("Post"),
text(`Amount (${base})`),
text("Original amount"),
text("Original currency"),
text("Financed"),
],
];
for (const monthId of months) {
for (const purchase of dataset.purchases) {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) continue;
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
for (const charge of charges) {
rows.push([
text(monthId),
text(purchase.date),
text(purchase.description),
text(postName.get(charge.postId) ?? charge.postId),
num(charge.amount),
num(slice.amount),
text(slice.currency),
text(purchase.schedule ? "yes" : "no"),
]);
}
}
}
return { name: "Purchases", rows };
}
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 = byMonth.get(monthId)?.get(postId);
rows.push([
text(monthId),
num(figures?.carriedIn ?? 0),
num(figures?.allocation ?? 0),
num(figures?.charges ?? 0),
num(figures?.remaining ?? 0),
]);
}
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, byMonth),
monthsSheet(months, views),
purchasesSheet(dataset, months),
...[...dataset.posts]
.sort((a, b) => a.order - b.order)
.map((post) => postSheet(post.id, post.name, months, byMonth)),
];
}
export function buildOds(dataset: Dataset): Uint8Array {
// Insertion order matters: `mimetype` must be the first entry and stored
// uncompressed (level 0) so the archive can be type-sniffed.
return zipSync(
{
mimetype: [strToU8(ODS_MIMETYPE), { level: 0 }],
"META-INF/manifest.xml": strToU8(buildManifestXml()),
"content.xml": strToU8(buildContentXml(buildWorkbook(dataset))),
"styles.xml": strToU8(buildStylesXml()),
},
{ level: 6 },
);
}