A functional toolkit for authoring resource-leveling algorithms — compose constraint and scoring blocks, enumerate feasible schedules
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(xlsx): declarative SheetSpec layer with formulas and named ranges

Adds writeSheets() and writeWithGanttAndSheets() driven by
CellSpec/SheetSpec: formula cells written without cached results so
Excel/LibreOffice recalculate on open, workbook named ranges, cell
notes, row outlines, and header/input/derived/muted/internal style
hooks. Formula strings stay opaque to the writer — pricing semantics
live in the consuming app's emitter. Spec sheets append after the
Gantt sheet so chart-injection sheet indices stay stable. The
named-range recalc test (row insertion absorbed by an open slot) runs
under LibreOffice headless when soffice is available.

Russ T. Fugal (Jun 12, 2026, 3:25 AM -0600) ff599a30 d18eb495

+318 -2
+9 -1
src/index.ts
··· 57 57 export { CsvWriter } from "./csv/CsvWriter.ts"; 58 58 export type { CsvWriterOptions } from "./csv/CsvWriter.ts"; 59 59 export { XlsxWriter } from "./xlsx/XlsxWriter.ts"; 60 - export type { XlsxWriterOptions } from "./xlsx/XlsxWriter.ts"; 60 + export type { 61 + XlsxWriterOptions, 62 + CellSpec, 63 + CellStyle, 64 + SheetSpec, 65 + GanttBar, 66 + GanttPhase, 67 + GanttSpec, 68 + } from "./xlsx/XlsxWriter.ts"; 61 69 export type { MspdiWriterOptions } from "./mspdi/MspdiWriter.ts"; 62 70 63 71 // Enums
+118 -1
src/xlsx/XlsxWriter.ts
··· 32 32 33 33 const DEFAULT_PHASE_COLORS = ["4472C4", "ED7D31"]; 34 34 35 + /** Visual style hooks for spec-driven sheets. The semantics (e.g. muted rows 36 + * placed outside SUM ranges, INTERNAL distribution warnings) are the 37 + * consuming app's layout concern; the writer only renders the style. */ 38 + export type CellStyle = "header" | "input" | "derived" | "muted" | "internal"; 39 + 40 + export interface CellSpec { 41 + value?: unknown; 42 + /** Formula text without leading "=". Written without a cached result so 43 + * Excel/LibreOffice recalculate on open. */ 44 + formula?: string; 45 + numFmt?: string; 46 + /** Cell comment (parameter notes). */ 47 + note?: string; 48 + style?: CellStyle; 49 + } 50 + 51 + export interface SheetSpec { 52 + name: string; 53 + rows: CellSpec[][]; 54 + /** Workbook-level defined names, e.g. { name: "Rate_Install_Blended", 55 + * range: "Pricing!$B$4" }. Emitters that write formulas against named 56 + * ranges only survive row insertion. */ 57 + namedRanges?: Array<{ name: string; range: string }>; 58 + /** 1-based inclusive row spans to group under an outline level. */ 59 + outlineRows?: Array<{ from: number; to: number; level: number }>; 60 + } 61 + 35 62 /** 36 63 * Converts a Date to an Excel serial number (days since 1899-12-30), 37 64 * avoiding timezone issues by working directly with date components. ··· 106 133 project: ProjectFile, 107 134 gantt: GanttSpec, 108 135 options?: XlsxWriterOptions, 136 + specSheets?: SheetSpec[], 109 137 ): Promise<Uint8Array> { 110 138 const flat: Array<{ phaseIdx: number; bar: GanttBar }> = []; 111 139 for (let pi = 0; pi < gantt.phases.length; pi += 1) { ··· 115 143 } 116 144 117 145 if (flat.length === 0) { 118 - return this.write(project, options); 146 + const workbook = this.buildWorkbook(project, options); 147 + if (specSheets) addSpecSheets(workbook, specSheets); 148 + const buffer = await workbook.xlsx.writeBuffer(); 149 + return new Uint8Array(buffer); 119 150 } 120 151 121 152 flat.sort((a, b) => { ··· 151 182 152 183 workbook.addWorksheet("Gantt"); 153 184 185 + // Spec sheets come after the Gantt sheet: injectGanttChart patches 186 + // sheet3.xml by position (Schedule, _Gantt Data, Gantt). 187 + if (specSheets) addSpecSheets(workbook, specSheets); 188 + 154 189 const buffer = await workbook.xlsx.writeBuffer(); 155 190 const patched = await injectGanttChart(buffer, { 156 191 items, ··· 162 197 return new Uint8Array(patched); 163 198 } 164 199 200 + /** 201 + * Writes a workbook from declarative sheet specs: literal values, formula 202 + * cells (no cached result), named ranges, cell notes, row outlines, and 203 + * the {@link CellStyle} hooks. Formula strings are opaque to the writer — 204 + * it writes what it is given. 205 + */ 206 + async writeSheets(sheets: SheetSpec[]): Promise<Uint8Array> { 207 + const workbook = new ExcelJS.Workbook(); 208 + addSpecSheets(workbook, sheets); 209 + const buffer = await workbook.xlsx.writeBuffer(); 210 + return new Uint8Array(buffer); 211 + } 212 + 213 + /** 214 + * Escape hatch: composes spec sheets into a workbook that also carries the 215 + * existing schedule/Gantt sheets. Spec sheets are appended after the Gantt 216 + * sheet so the chart-injection sheet indices stay stable. 217 + */ 218 + async writeWithGanttAndSheets( 219 + project: ProjectFile, 220 + gantt: GanttSpec, 221 + sheets: SheetSpec[], 222 + options?: XlsxWriterOptions, 223 + ): Promise<Uint8Array> { 224 + return this.writeWithGantt(project, gantt, options, sheets); 225 + } 226 + 165 227 private buildWorkbook(project: ProjectFile, options?: XlsxWriterOptions): ExcelJS.Workbook { 166 228 const sheetName = options?.sheetName ?? "Schedule"; 167 229 ··· 235 297 sheet.properties.outlineLevelRow = 4; 236 298 237 299 return workbook; 300 + } 301 + } 302 + 303 + function applyCellStyle(cell: ExcelJS.Cell, style: CellStyle): void { 304 + switch (style) { 305 + case "header": 306 + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF4472C4" } }; 307 + cell.font = { bold: true, color: { argb: "FFFFFFFF" } }; 308 + break; 309 + case "input": 310 + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFFF2CC" } }; 311 + break; 312 + case "derived": 313 + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF2F2F2" } }; 314 + break; 315 + case "muted": 316 + cell.font = { color: { argb: "FF808080" }, strike: true }; 317 + break; 318 + case "internal": 319 + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFCE4D6" } }; 320 + break; 321 + } 322 + } 323 + 324 + function addSpecSheets(workbook: ExcelJS.Workbook, sheets: SheetSpec[]): void { 325 + for (const spec of sheets) { 326 + const sheet = workbook.addWorksheet(spec.name); 327 + 328 + for (let r = 0; r < spec.rows.length; r += 1) { 329 + const rowSpec = spec.rows[r]!; 330 + for (let c = 0; c < rowSpec.length; c += 1) { 331 + const cellSpec = rowSpec[c]!; 332 + const cell = sheet.getCell(r + 1, c + 1); 333 + if (cellSpec.formula !== undefined) { 334 + // No cached result — Excel/LibreOffice recalculate on open. 335 + cell.value = { formula: cellSpec.formula } as ExcelJS.CellFormulaValue; 336 + } else if (cellSpec.value !== undefined) { 337 + cell.value = cellSpec.value as ExcelJS.CellValue; 338 + } 339 + if (cellSpec.numFmt) cell.numFmt = cellSpec.numFmt; 340 + if (cellSpec.note) cell.note = cellSpec.note; 341 + if (cellSpec.style) applyCellStyle(cell, cellSpec.style); 342 + } 343 + } 344 + 345 + for (const outline of spec.outlineRows ?? []) { 346 + for (let r = outline.from; r <= outline.to; r += 1) { 347 + // Excel caps the outline level at 7. 348 + sheet.getRow(r).outlineLevel = Math.min(outline.level, 7); 349 + } 350 + } 351 + 352 + for (const named of spec.namedRanges ?? []) { 353 + workbook.definedNames.add(named.range, named.name); 354 + } 238 355 } 239 356 } 240 357
+191
test/xlsx/formulas.test.ts
··· 1 + import { test, expect, describe } from "bun:test"; 2 + import { existsSync } from "node:fs"; 3 + import { mkdtemp, rm, writeFile } from "node:fs/promises"; 4 + import { tmpdir } from "node:os"; 5 + import { join } from "node:path"; 6 + 7 + import ExcelJS from "exceljs"; 8 + 9 + import { XlsxWriter, type SheetSpec } from "../../src/xlsx/XlsxWriter.ts"; 10 + 11 + // LibreOffice headless is the recalculation oracle (formula cells are 12 + // written without cached results). Skip recalc assertions when absent. 13 + const SOFFICE = 14 + Bun.which("soffice") ?? 15 + (existsSync("/Applications/LibreOffice.app/Contents/MacOS/soffice") 16 + ? "/Applications/LibreOffice.app/Contents/MacOS/soffice" 17 + : null); 18 + 19 + /** Pricing-style sheet exercising the req-13 open-slot mechanism: the 20 + * named range $B$2:$B$5 spans three data rows plus a blank open-slot row, 21 + * and the total row sums the *name*, not a cell range. */ 22 + function makePricingSpec(): SheetSpec { 23 + return { 24 + name: "Pricing", 25 + rows: [ 26 + [ 27 + { value: "Item", style: "header" }, 28 + { value: "Amount", style: "header" }, 29 + ], 30 + [{ value: "Install" }, { value: 10, style: "input", numFmt: "#,##0.00" }], 31 + [ 32 + { value: "Commission", note: "Rate held from 2025 master agreement" }, 33 + { value: 20, style: "input" }, 34 + ], 35 + [ 36 + { value: "Descoped extra", style: "muted" }, 37 + { value: 30, style: "muted" }, 38 + ], 39 + [{ value: "(open slot)" }, {}], 40 + [ 41 + { value: "Total", style: "derived" }, 42 + { formula: "SUM(Line_Amounts)", style: "derived" }, 43 + ], 44 + [ 45 + { value: "Margin floor", style: "internal" }, 46 + { value: 0.18, style: "internal" }, 47 + ], 48 + ], 49 + namedRanges: [{ name: "Line_Amounts", range: "Pricing!$B$2:$B$5" }], 50 + outlineRows: [{ from: 2, to: 5, level: 1 }], 51 + }; 52 + } 53 + 54 + async function loadWorkbook(buffer: Uint8Array): Promise<ExcelJS.Workbook> { 55 + const workbook = new ExcelJS.Workbook(); 56 + await workbook.xlsx.load(buffer); 57 + return workbook; 58 + } 59 + 60 + function noteText(note: ExcelJS.Cell["note"]): string { 61 + if (typeof note === "string") return note; 62 + return (note?.texts ?? []).map((t) => t.text).join(""); 63 + } 64 + 65 + describe("XlsxWriter.writeSheets — formulas, names, notes, styles", () => { 66 + test.skipIf(!SOFFICE)( 67 + "=SUM(named_range) recalculates after a row insertion within the range", 68 + async () => { 69 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 70 + 71 + // Insert a row inside the named range. The static $B$2:$B$5 still 72 + // covers every data row because the blank open slot absorbs the shift. 73 + const workbook = await loadWorkbook(buffer); 74 + const sheet = workbook.getWorksheet("Pricing")!; 75 + sheet.insertRow(3, ["Inserted line", 15]); 76 + 77 + const dir = await mkdtemp(join(tmpdir(), "levelset-recalc-")); 78 + try { 79 + const inPath = join(dir, "pricing.xlsx"); 80 + await writeFile(inPath, new Uint8Array(await workbook.xlsx.writeBuffer())); 81 + 82 + const proc = Bun.spawn( 83 + [SOFFICE!, "--headless", "--convert-to", "xlsx", "--outdir", join(dir, "out"), inPath], 84 + { stdout: "pipe", stderr: "pipe" }, 85 + ); 86 + const exitCode = await proc.exited; 87 + expect(exitCode).toBe(0); 88 + 89 + const converted = await loadWorkbook( 90 + new Uint8Array(await Bun.file(join(dir, "out", "pricing.xlsx")).arrayBuffer()), 91 + ); 92 + // Total row shifted from 6 to 7 by the insertion. 93 + const total = converted.getWorksheet("Pricing")!.getCell("B7"); 94 + expect(total.formula).toContain("Line_Amounts"); 95 + expect(total.result).toBe(10 + 15 + 20 + 30); 96 + } finally { 97 + await rm(dir, { recursive: true, force: true }); 98 + } 99 + }, 100 + 30000, 101 + ); 102 + 103 + test("formula cells survive a write/read round trip without cached results", async () => { 104 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 105 + const workbook = await loadWorkbook(buffer); 106 + const cell = workbook.getWorksheet("Pricing")!.getCell("B6"); 107 + expect(cell.formula).toBe("SUM(Line_Amounts)"); 108 + }); 109 + 110 + test("named ranges land in workbook.definedNames", async () => { 111 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 112 + const workbook = await loadWorkbook(buffer); 113 + const ranges = workbook.definedNames.getRanges("Line_Amounts"); 114 + expect(ranges.ranges).toEqual(["Pricing!$B$2:$B$5"]); 115 + }); 116 + 117 + test("cell notes survive the round trip", async () => { 118 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 119 + const workbook = await loadWorkbook(buffer); 120 + const cell = workbook.getWorksheet("Pricing")!.getCell("A3"); 121 + expect(noteText(cell.note)).toBe("Rate held from 2025 master agreement"); 122 + }); 123 + 124 + test("muted style renders grey + strikethrough", async () => { 125 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 126 + const workbook = await loadWorkbook(buffer); 127 + const cell = workbook.getWorksheet("Pricing")!.getCell("A4"); 128 + expect(cell.font?.strike).toBe(true); 129 + expect(cell.font?.color?.argb).toBe("FF808080"); 130 + }); 131 + 132 + test("header/input/derived/internal styles apply fills", async () => { 133 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 134 + const sheet = (await loadWorkbook(buffer)).getWorksheet("Pricing")!; 135 + 136 + const fgArgb = (addr: string) => { 137 + const fill = sheet.getCell(addr).fill; 138 + return fill.type === "pattern" ? fill.fgColor?.argb : undefined; 139 + }; 140 + expect(fgArgb("A1")).toBe("FF4472C4"); 141 + expect(sheet.getCell("A1").font?.bold).toBe(true); 142 + expect(fgArgb("B2")).toBe("FFFFF2CC"); 143 + expect(fgArgb("B6")).toBe("FFF2F2F2"); 144 + expect(fgArgb("B7")).toBe("FFFCE4D6"); 145 + }); 146 + 147 + test("numFmt and outline rows apply", async () => { 148 + const buffer = await new XlsxWriter().writeSheets([makePricingSpec()]); 149 + const sheet = (await loadWorkbook(buffer)).getWorksheet("Pricing")!; 150 + expect(sheet.getCell("B2").numFmt).toBe("#,##0.00"); 151 + expect(sheet.getRow(3).outlineLevel).toBe(1); 152 + expect(sheet.getRow(6).outlineLevel ?? 0).toBe(0); 153 + }); 154 + 155 + test("writeSheets supports multiple sheets", async () => { 156 + const buffer = await new XlsxWriter().writeSheets([ 157 + makePricingSpec(), 158 + { name: "Assumptions", rows: [[{ value: "Assumption" }]] }, 159 + ]); 160 + const workbook = await loadWorkbook(buffer); 161 + expect(workbook.getWorksheet("Pricing")).toBeDefined(); 162 + expect(workbook.getWorksheet("Assumptions")).toBeDefined(); 163 + }); 164 + }); 165 + 166 + describe("XlsxWriter.writeWithGanttAndSheets", () => { 167 + test("spec sheets compose with the schedule + Gantt sheets", async () => { 168 + const { makeMinimalProject } = await import("../helpers.ts"); 169 + const project = makeMinimalProject(); 170 + 171 + const buffer = await new XlsxWriter().writeWithGanttAndSheets( 172 + project, 173 + { 174 + phases: [ 175 + { 176 + label: "Phase 1", 177 + items: [{ name: "Task A", start: "2026-01-05", finish: "2026-01-09" }], 178 + }, 179 + ], 180 + }, 181 + [makePricingSpec()], 182 + ); 183 + 184 + const workbook = await loadWorkbook(buffer); 185 + expect(workbook.getWorksheet("Schedule")).toBeDefined(); 186 + expect(workbook.getWorksheet("Gantt")).toBeDefined(); 187 + const pricing = workbook.getWorksheet("Pricing")!; 188 + expect(pricing.getCell("B6").formula).toBe("SUM(Line_Amounts)"); 189 + expect(workbook.definedNames.getRanges("Line_Amounts").ranges).toEqual(["Pricing!$B$2:$B$5"]); 190 + }); 191 + });