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 }, + ); +}