Recognise a subtotal by its zero-width padding

Perspective pads a subtotal's column path to full length rather than
shortening it -- ['04 - Forecast', '​', 'sales_usd'] -- so testing for
an empty string found no subtotals and nothing was tinted. The blank test now
strips zero-width spaces and the other invisibles alongside whitespace.

The grand total falls out of the same rule, its path being blank at every
level above the measure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 09:26:21 -04:00
parent 54d49ebc75
commit f9424d9c42

View File

@ -942,6 +942,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// data that only the cell metadata knows. The listener runs on every draw, so
// it survives scrolling and virtualisation -- a stylesheet cannot, since the
// DOM cells are recycled across columns as you scroll.
// Blank for this purpose means "no value at this level", which Perspective
// writes as a zero-width space rather than an empty string.
const notBlank = (v) =>
v != null && String(v).replace(/[\s\u200b-\u200d\ufeff]/g, '') !== ''
function applyGroupRules() {
const grid = gridRef.current
const table = grid?.regular_table
@ -967,6 +972,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const body = table.querySelectorAll('tbody td')
// The deepest column path is a leaf; anything shorter is an aggregate of
// the levels below it, which is what makes a subtotal a subtotal.
//
// The empty levels are not empty strings. Perspective pads a subtotal's
// path with zero-width spaces -- ['04 - Forecast', '\u200b', 'sales_usd']
// -- so every path is the same length and a naive `!== ''` test finds no
// subtotals at all.
let depth = 0
const metas = []
for (const td of body) {
@ -974,7 +984,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
try { meta = table.getMeta(td) } catch { meta = null }
metas.push([td, meta])
const path = meta?.column_header
if (Array.isArray(path)) depth = Math.max(depth, path.filter(v => v != null && v !== '').length)
if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length)
}
let prevGroup = null
@ -982,7 +992,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
td.classList.remove('pf-group-start', 'pf-subtotal')
const path = meta?.column_header
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
const named = path.filter(v => v != null && v !== '')
const named = path.filter(notBlank)
const group = named[0]
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
if (named.length < depth) td.classList.add('pf-subtotal')