diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index dfb82fae09..1b0076f1a8 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -253,7 +253,7 @@ import { allNullColumnIndexes } from "@/lib/dataGrid/dataGridColumnVisibility"; import { buildDataGridColumnLookupItems, dataGridColumnCommentFor, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup"; import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder"; import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; -import { summarizeSelection } from "@/lib/dataGrid/gridSelection"; +import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection"; import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence"; import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames"; import { @@ -5554,12 +5554,7 @@ const multiRowCount = computed(() => { const selectionSummary = computed(() => { if (!hasCellSelection.value) return null; if (isSelectingCells.value) { - return { - cellCount: selectedCellCount.value, - rowCount: multiRowCount.value, - numericCount: 0, - sum: 0, - }; + return createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value); } return summarizeSelection(selectedCells.value); }); @@ -5567,8 +5562,11 @@ const selectionSummarySumText = computed(() => { if (isSelectingCells.value) return "…"; const summary = selectionSummary.value; if (!summary) return "0"; - const sum = Object.is(summary.sum, -0) ? 0 : summary.sum; - return Number.isInteger(sum) ? String(sum) : sum.toLocaleString(undefined, { maximumFractionDigits: 12 }); + return formatSelectionAggregate(summary.sum); +}); +const selectionSummaryAverageText = computed(() => { + if (isSelectingCells.value) return "…"; + return formatSelectionAverage(selectionSummary.value?.average); }); const isMultiRow = computed(() => multiRowCount.value > 1); @@ -13587,6 +13585,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] { :pagination-enabled="paginationEnabled" :selection-summary="selectionSummary" :selection-summary-sum-text="selectionSummarySumText" + :selection-summary-average-text="selectionSummaryAverageText" :loading="gridPaginationBusy" :infinite-scroll-enabled="infiniteScrollEnabled" :infinite-scroll-all-loaded="infiniteScrollAllLoaded" diff --git a/apps/desktop/src/components/grid/DataGridPagination.vue b/apps/desktop/src/components/grid/DataGridPagination.vue index ca1142c91c..1c4651e5e0 100644 --- a/apps/desktop/src/components/grid/DataGridPagination.vue +++ b/apps/desktop/src/components/grid/DataGridPagination.vue @@ -16,6 +16,7 @@ const props = withDefaults( paginationEnabled?: boolean; selectionSummary: { cellCount: number; rowCount: number } | null; selectionSummarySumText: string; + selectionSummaryAverageText: string; loading: boolean; infiniteScrollEnabled: boolean; infiniteScrollAllLoaded: boolean; @@ -86,6 +87,7 @@ function handlePageInputKeydown(event: KeyboardEvent) {
{{ t("grid.selectionSum", { value: selectionSummarySumText }) }} + {{ t("grid.selectionAverage", { value: selectionSummaryAverageText }) }}
{{ t("grid.selectionCells", { count: selectionSummary.cellCount }) }} {{ t("grid.rows", { count: selectionSummary.rowCount }) }} diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 77a2177d83..a0cebb3d13 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { readFileSync } from "node:fs"; import { nextTick } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { dispatch, findAll, findOne, hostText, mountComponent } from "./vueHostHarness"; @@ -98,6 +99,8 @@ import DataGridPagination from "@/components/grid/DataGridPagination.vue"; import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue"; import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue"; +const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + function detail(patch: Partial = {}): DataGridCellDetail { return { rowNumber: 1, @@ -184,6 +187,42 @@ describe("DataGridSearchBar", () => { }); describe("DataGridPagination", () => { + it("keeps drag summaries count-only and wires the derived average without materializing selected cells", () => { + const summaryStart = dataGridSource.indexOf("const selectionSummary = computed"); + const pendingSummary = dataGridSource.indexOf("createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value)", summaryStart); + const materializedSummary = dataGridSource.indexOf("summarizeSelection(selectedCells.value)", summaryStart); + + expect(summaryStart).toBeGreaterThan(-1); + expect(pendingSummary).toBeGreaterThan(summaryStart); + expect(materializedSummary).toBeGreaterThan(pendingSummary); + expect(dataGridSource).toContain('const selectionSummaryAverageText = computed(() => {\n if (isSelectingCells.value) return "…";'); + expect(dataGridSource).toContain(':selection-summary-average-text="selectionSummaryAverageText"'); + }); + + it("shows average beside the existing selection summary values", () => { + const mounted = mountComponent(DataGridPagination, { + selectionSummary: { cellCount: 4, rowCount: 2 }, + selectionSummarySumText: "10", + selectionSummaryAverageText: "2.5", + loading: false, + infiniteScrollEnabled: false, + infiniteScrollAllLoaded: false, + pageSize: 100, + customPageSizeInput: "", + pageSizeMenuItems: [], + exportMenuItems: [], + currentPage: 1, + canGoNextPage: false, + canJumpLastPage: false, + }); + + const summaryText = hostText(mounted.root); + expect(summaryText).toContain("grid.selectionSum"); + expect(summaryText).toContain("grid.selectionAverage"); + expect(summaryText).toContain("grid.selectionCells"); + expect(summaryText).toContain("grid.rows"); + }); + it("enforces first/previous/next/last disabled boundaries", async () => { const firstPage = vi.fn(); const previousPage = vi.fn(); @@ -192,6 +231,7 @@ describe("DataGridPagination", () => { const mounted = mountComponent(DataGridPagination, { selectionSummary: null, selectionSummarySumText: "", + selectionSummaryAverageText: "", loading: false, infiniteScrollEnabled: false, infiniteScrollAllLoaded: false, @@ -236,6 +276,7 @@ describe("DataGridPagination", () => { const mounted = mountComponent(DataGridPagination, { selectionSummary: null, selectionSummarySumText: "", + selectionSummaryAverageText: "", loading: false, infiniteScrollEnabled: false, infiniteScrollAllLoaded: false, @@ -278,6 +319,7 @@ describe("DataGridPagination", () => { paginationEnabled: false, selectionSummary: null, selectionSummarySumText: "", + selectionSummaryAverageText: "", loading: false, infiniteScrollEnabled: false, infiniteScrollAllLoaded: false, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 06222f4776..688a003d05 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1603,6 +1603,7 @@ export default { copyColumnNamesPreview: "Preview", copyAll: "Copy All (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} cells", clearSelection: "Clear Selection", export: "Export", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 880cda0fbf..638d279cea 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1493,6 +1493,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "Vista previa", copyAll: "Copiar todo (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} celdas", clearSelection: "Limpiar selección", export: "Exportar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9f988a3735..b3a4b69551 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1491,6 +1491,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "Anteprima", copyAll: "Copia Tutto (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} celle", clearSelection: "Cancella Selezione", export: "Esporta", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0b2d95dcab..2292e85e4a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1511,6 +1511,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "プレビュー", copyAll: "すべてコピー (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} 個のセル", clearSelection: "選択をクリア", export: "エクスポート", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e230a5a0fe..f84c9e63a5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1483,6 +1483,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "미리보기", copyAll: "모두 복사 (TSV)", selectionSum: "합계: {value}", + selectionAverage: "평균: {value}", selectionCells: "{count}개 셀", clearSelection: "선택 해제", export: "내보내기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a6321b9e21..b8556eff1a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1493,6 +1493,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "Pré-visualização", copyAll: "Copiar Tudo (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} células", clearSelection: "Limpar Seleção", export: "Exportar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index edee0ecc49..268e141d7b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1527,6 +1527,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "预览", copyAll: "复制全部 (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} 个单元", clearSelection: "清除选区", export: "导出", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f1db12f980..7a3fb190b4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1492,6 +1492,7 @@ export default withEnglishFallback({ copyColumnNamesPreview: "預覽", copyAll: "複製全部 (TSV)", selectionSum: "SUM: {value}", + selectionAverage: "AVG: {value}", selectionCells: "{count} 個儲存格", clearSelection: "清除選區", export: "匯出", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts index 0666e3c847..e3a02cbd31 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/gridSelection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { dedupeColumnIndexes, summarizeSelection } from "@/lib/dataGrid/gridSelection"; +import { createPendingSelectionSummary, dedupeColumnIndexes, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection"; describe("gridSelection", () => { it("summarizes empty selections", () => { @@ -8,6 +8,7 @@ describe("gridSelection", () => { rowCount: 0, numericCount: 0, sum: 0, + average: null, }); }); @@ -25,6 +26,7 @@ describe("gridSelection", () => { rowCount: 2, numericCount: 4, sum: 10, + average: 2.5, }); }); @@ -42,9 +44,58 @@ describe("gridSelection", () => { rowCount: 2, numericCount: 3, sum: 105.5, + average: 105.5 / 3, }); }); + it("uses the same numeric participation rules for sum and average", () => { + expect( + summarizeSelection({ + columns: ["value"], + rows: [[null], [true], [false], [""], [" "], ["not a number"], [Number.NaN], [Number.POSITIVE_INFINITY]], + }), + ).toEqual({ + cellCount: 8, + rowCount: 8, + numericCount: 0, + sum: 0, + average: null, + }); + }); + + it.each([ + ["single cell", [[8]], 1, 1, 8], + ["row", [[1, 3]], 2, 1, 2], + ["column", [[1], [3]], 2, 2, 2], + ["range", [[1, 2], [3, 4]], 4, 2, 2.5], + ] as const)("averages a %s selection without changing its counts", (_label, rows, cellCount, rowCount, average) => { + expect(summarizeSelection({ columns: [], rows: rows.map((row) => [...row]) })).toMatchObject({ cellCount, rowCount, average }); + }); + + it("does not expose an infinite average when a finite-value sum overflows", () => { + expect(summarizeSelection({ columns: ["value"], rows: [[Number.MAX_VALUE], [Number.MAX_VALUE]] })).toMatchObject({ + numericCount: 2, + sum: Number.POSITIVE_INFINITY, + average: null, + }); + }); + + it("keeps pending summaries count-only and formats sum and average identically", () => { + expect(createPendingSelectionSummary(1_000_000, 100_000)).toEqual({ + cellCount: 1_000_000, + rowCount: 100_000, + numericCount: 0, + sum: 0, + average: null, + }); + expect(formatSelectionAggregate(-0, "en-US")).toBe("0"); + expect(formatSelectionAggregate(1 / 3, "en-US")).toBe("0.333333333333"); + expect(formatSelectionAggregate(2.5, "en-US")).toBe("2.5"); + expect(formatSelectionAverage(1 / 3, "en-US")).toBe("0.333333333333"); + expect(formatSelectionAverage(null, "en-US")).toBe("—"); + expect(formatSelectionAverage(Number.POSITIVE_INFINITY, "en-US")).toBe("—"); + }); + it("dedupeColumnIndexes preserves the first visible occurrence instead of sorting", () => { // After drag-reordering, visible order is [2, 0, 1]; extraction must keep it, // not revert to numeric [0, 1, 2]. diff --git a/apps/desktop/src/lib/dataGrid/gridSelection.ts b/apps/desktop/src/lib/dataGrid/gridSelection.ts index 7c903af8fc..76da725b8c 100644 --- a/apps/desktop/src/lib/dataGrid/gridSelection.ts +++ b/apps/desktop/src/lib/dataGrid/gridSelection.ts @@ -27,6 +27,7 @@ export interface SelectionSummary { rowCount: number; numericCount: number; sum: number; + average: number | null; } export function parseClipboardTable(text: string): string[][] { @@ -123,5 +124,25 @@ export function summarizeSelection(selection: SelectionData): SelectionSummary { rowCount: selection.rows.length, numericCount, sum, + average: numericCount > 0 && Number.isFinite(sum) ? sum / numericCount : null, }; } + +export function createPendingSelectionSummary(cellCount: number, rowCount: number): SelectionSummary { + return { + cellCount, + rowCount, + numericCount: 0, + sum: 0, + average: null, + }; +} + +export function formatSelectionAggregate(value: number, locales?: Intl.LocalesArgument): string { + const normalized = Object.is(value, -0) ? 0 : value; + return Number.isInteger(normalized) ? String(normalized) : normalized.toLocaleString(locales, { maximumFractionDigits: 12 }); +} + +export function formatSelectionAverage(value: number | null | undefined, locales?: Intl.LocalesArgument): string { + return typeof value === "number" && Number.isFinite(value) ? formatSelectionAggregate(value, locales) : "—"; +}