From 69bf4393fb3f6c75654f5ca35186258d76bbe78d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 20:11:08 +0200 Subject: [PATCH] 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. --- bun.lock | 3 + package.json | 1 + src/domain/views.test.ts | 34 ++++++++- src/domain/views.ts | 26 ++++++- src/export/ods.test.ts | 104 ++++++++++++++++++++++++++++ src/export/ods.ts | 146 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 src/export/ods.test.ts create mode 100644 src/export/ods.ts diff --git a/bun.lock b/bun.lock index bff47b9..08c6beb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 8978645..1962a3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index e6b3e83..9158c30 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -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" }); +}); diff --git a/src/domain/views.ts b/src/domain/views.ts index e1b2ac0..5282e37 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -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 }; +} diff --git a/src/export/ods.test.ts b/src/export/ods.test.ts new file mode 100644 index 0000000..f8aa687 --- /dev/null +++ b/src/export/ods.test.ts @@ -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"); +}); diff --git a/src/export/ods.ts b/src/export/ods.ts new file mode 100644 index 0000000..fb6e5e6 --- /dev/null +++ b/src/export/ods.ts @@ -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 }, + ); +}