From ffda566fd3d589ce7f56dc271c16126d8c62a0db Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:08:23 -0800 Subject: [PATCH] feat(web): add spreadsheet data transform utilities --- .../components/workspace/spreadsheet-utils.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 apps/web/app/components/workspace/spreadsheet-utils.ts diff --git a/apps/web/app/components/workspace/spreadsheet-utils.ts b/apps/web/app/components/workspace/spreadsheet-utils.ts new file mode 100644 index 00000000000..055c9528109 --- /dev/null +++ b/apps/web/app/components/workspace/spreadsheet-utils.ts @@ -0,0 +1,124 @@ +import type { CellBase, Matrix, Selection, Point } from "react-spreadsheet"; +import { createEmptyMatrix } from "react-spreadsheet"; +import { utils, type WorkSheet } from "xlsx"; + +// --------------------------------------------------------------------------- +// File extension helpers +// --------------------------------------------------------------------------- + +const TEXT_EXTENSIONS = new Set(["csv", "tsv"]); + +export function fileExt(filename: string): string { + return filename.split(".").pop()?.toLowerCase() ?? ""; +} + +export function isTextSpreadsheet(filename: string): boolean { + return TEXT_EXTENSIONS.has(fileExt(filename)); +} + +// --------------------------------------------------------------------------- +// Cell reference helpers +// --------------------------------------------------------------------------- + +/** Convert zero-based column index to Excel-style label (A, B, ..., Z, AA, AB, ...) */ +export function columnLabel(idx: number): string { + let label = ""; + let n = idx; + do { + label = String.fromCharCode(65 + (n % 26)) + label; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return label; +} + +/** Format a cell coordinate as an Excel-style reference like "C7" */ +export function cellRef(point: Point): string { + return `${columnLabel(point.column)}${point.row + 1}`; +} + +// --------------------------------------------------------------------------- +// Data conversion +// --------------------------------------------------------------------------- + +/** Convert an xlsx WorkSheet to a react-spreadsheet data matrix */ +export function sheetToMatrix(sheet: WorkSheet): Matrix { + const raw: unknown[][] = utils.sheet_to_json(sheet, { + header: 1, + defval: "", + }); + if (raw.length === 0) {return createEmptyMatrix(1, 1);} + + const maxCols = raw.reduce((m, r) => Math.max(m, r.length), 0); + return raw.map((row) => { + const cells: (CellBase | undefined)[] = []; + for (let c = 0; c < maxCols; c++) { + const v = c < row.length ? row[c] : ""; + cells.push({ value: v == null ? "" : v }); + } + return cells; + }); +} + +/** Convert a react-spreadsheet data matrix to an xlsx WorkSheet */ +export function matrixToSheet(data: Matrix): WorkSheet { + const aoa: unknown[][] = data.map((row) => + (row ?? []).map((cell) => (cell ? cell.value : "")), + ); + return utils.aoa_to_sheet(aoa); +} + +/** Convert data matrix to CSV text */ +export function matrixToCsv(data: Matrix, sep = ","): string { + return data + .map((row) => + (row ?? []) + .map((cell) => { + const v = cell ? String(cell.value) : ""; + if (v.includes(sep) || v.includes('"') || v.includes("\n")) { + return `"${v.replace(/"/g, '""')}"`; + } + return v; + }) + .join(sep), + ) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Selection statistics +// --------------------------------------------------------------------------- + +export type SelectionStatsResult = { + count: number; + numericCount: number; + sum: number; + avg: number; +}; + +/** Compute summary stats for numeric cells in current selection */ +export function selectionStats( + data: Matrix, + sel: Selection | null, +): SelectionStatsResult | null { + if (!sel) {return null;} + const range = sel.toRange(data); + if (!range) {return null;} + + let count = 0; + let numericCount = 0; + let sum = 0; + + for (const pt of range) { + const cell = data[pt.row]?.[pt.column]; + if (!cell) {continue;} + count++; + const n = Number(cell.value); + if (cell.value !== "" && !isNaN(n)) { + numericCount++; + sum += n; + } + } + + if (count <= 1) {return null;} + return { count, numericCount, sum, avg: numericCount > 0 ? sum / numericCount : 0 }; +}