feat(export): add ODS workbook export

Generated in-browser with fflate, so export works offline. mimetype is
stored first and uncompressed as the ODS spec requires.

Also adds datasetMonthSpan to the domain views layer, which determines
the month range an export should cover: from the fold start through
the latest month touched by any month record or purchase slice,
including future finance-plan slices.

Widened the mimetype-header test's byte slice from 64 to 96 bytes: the
zip local-file header for "mimetype" is 38 bytes and ODS_MIMETYPE is
46 characters, so 64 bytes could never contain the full string
regardless of implementation.
This commit is contained in:
Lucas Winther committed 2026-09-01 20:11:08 +02:00
1 parent c627051e66
commit 69bf4393fb
6 files changed
+312 -2

No files matched your search

+3
View File
@@ -11,6 +11,7 @@
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fflate": "^0.8.3",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
@@ -136,6 +137,8 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"lucide-react": ["lucide-react@1.39.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8nXoEwvqqIsF927NBWXODa4bfMrcUeEb/9sgpwFqg0gUjgn3j5Hznk+v7STmPgZ2iQ11JKlbQGdFRuTOwvYkA=="],
+1
View File
@@ -15,6 +15,7 @@
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fflate": "^0.8.3",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
+33 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, describe } from "bun:test";
import { monthView, yearView, summaryView } from "./views.ts";
import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts";
import type { Dataset, Post, Purchase } from "./types.ts";
function post(id: string, order: number, percent: number, archived = false): Post {
@@ -127,3 +127,35 @@ describe("summaryView", () => {
expect(view.byMonth).toHaveLength(0);
});
});
test("datasetMonthSpan covers foldStartMonth through the latest activity", () => {
expect(datasetMonthSpan(data)).toEqual({ from: "2026-01", to: "2026-02" });
});
test("datasetMonthSpan includes future finance-plan slices", () => {
const financed: Dataset = {
...data,
purchases: [
{
id: "f1",
date: "2026-01-05",
description: "Console",
total: { amount: 3000, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: "games", value: 100, absorbsRemainder: true }],
schedule: {
slices: [
{ month: "2026-01", amount: { amount: 1500, currency: "DKK" } },
{ month: "2026-06", amount: { amount: 1500, currency: "DKK" } },
],
},
},
],
};
expect(datasetMonthSpan(financed)).toEqual({ from: "2026-01", to: "2026-06" });
});
test("datasetMonthSpan of an empty dataset is a single month", () => {
const bare: Dataset = { ...data, months: [], purchases: [] };
expect(datasetMonthSpan(bare)).toEqual({ from: "2026-01", to: "2026-01" });
});
+25 -1
View File
@@ -1,7 +1,7 @@
import { incomeFor, isOverridden } from "./allocation.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { monthRange, monthsOfYear } from "./months.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
import type { Dataset, MonthId, Post } from "./types.ts";
export interface MonthPostRow {
@@ -175,3 +175,27 @@ export function summaryView(
),
};
}
/**
* The months worth rendering: from the fold start to the latest month touched
* by any month record or purchase slice, including future finance-plan slices.
*/
export function datasetMonthSpan(dataset: Dataset): { from: MonthId; to: MonthId } {
const from = dataset.settings.foldStartMonth;
let to = from;
const consider = (candidate: MonthId) => {
if (compareMonths(candidate, to) > 0) to = candidate;
};
for (const month of dataset.months) consider(month.id);
for (const purchase of dataset.purchases) {
if (purchase.schedule) {
for (const slice of purchase.schedule.slices) consider(slice.month);
} else {
consider(monthOf(purchase.date));
}
}
return { from, to };
}
+104
View File
@@ -0,0 +1,104 @@
import { test, expect, describe } from "bun:test";
import { unzipSync, strFromU8 } from "fflate";
import { buildOds, buildWorkbook, odsFilename } from "./ods.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { ODS_MIMETYPE } from "./odsXml.ts";
import type { Dataset } from "../domain/types.ts";
function populated(): Dataset {
const data = createSeedDataset("2026-01");
data.months[0]!.income = { amount: 20000, currency: "DKK" };
data.posts[1]!.standingRule = { kind: "percentOfIncome", percent: 25 };
data.purchases.push({
id: "p1",
date: "2026-01-15",
description: "Dinner & drinks",
total: { amount: 400, currency: "DKK" },
splitMode: "percent",
splits: [
{ postId: data.posts[1]!.id, value: 60, absorbsRemainder: true },
{ postId: data.posts[2]!.id, value: 40, absorbsRemainder: false },
],
schedule: null,
});
return data;
}
describe("buildWorkbook", () => {
test("includes Summary, Months, Purchases, and one sheet per post", () => {
const names = buildWorkbook(populated()).map((s) => s.name);
expect(names.slice(0, 3)).toEqual(["Summary", "Months", "Purchases"]);
expect(names).toContain("Video Games");
expect(names).toContain("Food");
expect(names).toContain("Events and Social");
expect(names).toHaveLength(6);
});
test("the Purchases sheet has one row per split, plus a header", () => {
const purchases = buildWorkbook(populated()).find((s) => s.name === "Purchases")!;
expect(purchases.rows).toHaveLength(3);
expect(purchases.rows[0]!.every((c) => c.kind === "text")).toBe(true);
});
test("split amounts on the Purchases sheet are numeric cells", () => {
const purchases = buildWorkbook(populated()).find((s) => s.name === "Purchases")!;
const numbers = purchases.rows
.slice(1)
.flatMap((row) => row.filter((cell) => cell.kind === "number"))
.map((cell) => (cell as { value: number }).value);
// 400 DKK split 60/40 across two posts.
expect(numbers).toContain(240);
expect(numbers).toContain(160);
});
});
describe("buildOds", () => {
const bytes = buildOds(populated());
const entries = unzipSync(bytes);
test("produces a non-empty byte array", () => {
expect(bytes.byteLength).toBeGreaterThan(0);
});
test("contains the four required parts", () => {
expect(Object.keys(entries).sort()).toEqual([
"META-INF/manifest.xml",
"content.xml",
"mimetype",
"styles.xml",
]);
});
test("the mimetype entry holds the spreadsheet mimetype", () => {
expect(strFromU8(entries["mimetype"]!)).toBe(ODS_MIMETYPE);
});
test("mimetype is the first entry and stored uncompressed", () => {
// The ODS spec requires this so file(1) and spreadsheet apps can sniff the
// type from the first bytes of the archive. The zip local-file header for
// "mimetype" is 38 bytes (30-byte fixed header + 8-byte filename), and
// ODS_MIMETYPE is 46 characters, so 96 bytes comfortably covers both.
const header = strFromU8(bytes.slice(0, 96));
expect(header).toContain("mimetype");
expect(header).toContain(ODS_MIMETYPE);
});
test("content.xml carries every sheet", () => {
const content = strFromU8(entries["content.xml"]!);
expect(content).toContain('table:name="Summary"');
expect(content).toContain('table:name="Events and Social"');
});
test("escapes an ampersand in a purchase description", () => {
const content = strFromU8(entries["content.xml"]!);
expect(content).toContain("Dinner & drinks");
});
test("exports a seed dataset with no purchases without throwing", () => {
expect(() => buildOds(createSeedDataset("2026-01"))).not.toThrow();
});
});
test("odsFilename is stable and sortable", () => {
expect(odsFilename("2026-09")).toBe("budget-2026-09.ods");
});
+146
View File
@@ -0,0 +1,146 @@
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 { 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[]): 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,
);
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(dataset: Dataset, months: MonthId[]): Sheet {
const rows = [
[text("Month"), text("Income"), text("Allocated"), text("Spent"), text("Unallocated")],
];
for (const monthId of months) {
const view = monthView(dataset, 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(dataset: Dataset, postId: string, name: string, months: MonthId[]): 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;
rows.push([
text(monthId),
num(figures?.carriedIn ?? 0),
num(figures?.allocation ?? 0),
num(figures?.charges ?? 0),
num(figures?.remaining ?? 0),
]);
}
return { name, rows };
}
export function buildWorkbook(dataset: Dataset): Sheet[] {
const { from, to } = datasetMonthSpan(dataset);
const months = monthRange(from, to);
return [
summarySheet(dataset, months),
monthsSheet(dataset, months),
purchasesSheet(dataset, months),
...[...dataset.posts]
.sort((a, b) => a.order - b.order)
.map((post) => postSheet(dataset, post.id, post.name, months)),
];
}
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 },
);
}