Skip to content

Commit a426d27

Browse files
committed
fix: hide XBRL hypercube scaffolding rows (Table / Line Items)
Stripping the role tags exposed that us-gaap:StatementTable and us-gaap:StatementLineItems render as bare, duplicated 'Statement' header rows — pure hypercube plumbing that carries no facts and is not a meaningful heading. Skip these abstracts in the pivot row walk and keep their children at the current depth so the tree stays flat. The check keys off the concept's local name (…Table / …LineItems), which is identical whether elements are keyed by IRI (store/holon route) or qname (SEC route), so both rendering routes hide them the same way. Axis/Domain/Member/Abstract/Roll Forward rows are unchanged.
1 parent 0773ecf commit a426d27

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

src/pivot.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,20 @@ function fallbackElement(id: string): ElementInfo {
231231
const elementOf = (model: NormalizedReport, id: string): ElementInfo =>
232232
model.elements[id] ?? fallbackElement(id)
233233

234+
/**
235+
* Pure XBRL hypercube plumbing: a `[Table]` (the hypercube itself) and its
236+
* `[Line Items]` container. These abstracts exist only to hang dimensional facts
237+
* off of — they carry no data, and once their role tag is stripped for display
238+
* they collapse to a bare, duplicated "Statement" heading. So they are never a
239+
* meaningful row: skip the header and promote their children in their place.
240+
* Keyed off the concept's local name (identical across the store and SEC routes,
241+
* which key elements by IRI vs qname respectively) so both render the same.
242+
*/
243+
const SCAFFOLD_LOCAL_RE = /(?:Table|LineItems)$/
244+
function isStructuralScaffold(el: ElementInfo): boolean {
245+
return el.abstract && SCAFFOLD_LOCAL_RE.test(localName(el.qname))
246+
}
247+
234248
// ── Default configuration ───────────────────────────────────────────────────
235249

236250
/**
@@ -711,7 +725,10 @@ export function buildPivot(
711725
seenInTree.add(id)
712726
const el = elementOf(model, id)
713727
if (el.abstract) {
714-
if (config.showAbstracts) {
728+
// Hypercube scaffolding ([Table]/[Line Items]) is not a heading — hide its
729+
// row and keep its children at the current depth so the tree stays flat.
730+
const scaffold = isStructuralScaffold(el)
731+
if (config.showAbstracts && !scaffold) {
715732
rows.push({
716733
key: id,
717734
element: el,
@@ -722,7 +739,8 @@ export function buildPivot(
722739
cells: [],
723740
})
724741
}
725-
for (const child of tree.childrenOf.get(id) ?? []) walk(child, depth + 1)
742+
const childDepth = scaffold ? depth : depth + 1
743+
for (const child of tree.childrenOf.get(id) ?? []) walk(child, childDepth)
726744
} else {
727745
for (const child of tree.childrenOf.get(id) ?? []) walk(child, depth + 1)
728746
emitConcept(id, depth)

test/pivot.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,84 @@ describe('buildPivot — incomplete period columns are dropped', () => {
444444
})
445445
})
446446

447+
describe('buildPivot — hides XBRL hypercube scaffolding rows', () => {
448+
// A [Table] and its [Line Items] container sit between the section header and
449+
// the real concepts. They carry no facts and (once their role tag is stripped)
450+
// read as bare, duplicated "Statement" — pure plumbing, not headings. Elements
451+
// are keyed by qname here, mirroring the SEC route (the store route keys by
452+
// IRI); the scaffold check keys off the local name so both behave identically.
453+
const SEC_ABS = 'us-gaap:IncomeStatementAbstract'
454+
const TABLE = 'us-gaap:StatementTable'
455+
const LINEITEMS = 'us-gaap:StatementLineItems'
456+
const REV = 'us-gaap:Revenues'
457+
458+
function scaffoldReport(): NormalizedReport {
459+
fid = 0
460+
return {
461+
reportId: 'r',
462+
reportIri: null,
463+
entity: null,
464+
informationBlocks: [
465+
{ id: 's', blockType: 'income_statement', factSet: 'fs', label: null, structureId: 's' },
466+
],
467+
structures: [
468+
{
469+
id: 's',
470+
blockType: 'income_statement',
471+
roleUri: null,
472+
structureName: 'Income',
473+
order: 0,
474+
},
475+
],
476+
facts: [fact(REV, 'p', 383285000000)],
477+
elements: {
478+
[SEC_ABS]: el(SEC_ABS, { abstract: true }),
479+
[TABLE]: el(TABLE, { abstract: true }),
480+
[LINEITEMS]: el(LINEITEMS, { abstract: true }),
481+
[REV]: el(REV, { monetary: true, periodType: 'duration', numericKind: 'monetary' }),
482+
},
483+
periods: {
484+
p: {
485+
id: 'p',
486+
type: 'duration',
487+
instant: null,
488+
startDate: '2022-09-25',
489+
endDate: '2023-09-30',
490+
end: '2023-09-30',
491+
},
492+
},
493+
units: { 'u:usd': { id: 'u:usd', measure: 'iso4217:USD', label: 'USD', symbol: '$' } },
494+
calcAssociations: [],
495+
presAssociations: [
496+
{ parent: SEC_ABS, child: TABLE, order: 0, role: null, structure: 's' },
497+
{ parent: TABLE, child: LINEITEMS, order: 0, role: null, structure: 's' },
498+
{ parent: LINEITEMS, child: REV, order: 0, role: null, structure: 's' },
499+
],
500+
}
501+
}
502+
503+
const p = buildPivot(scaffoldReport(), scaffoldReport().informationBlocks[0])
504+
505+
it('emits no header row for the [Table]/[Line Items] plumbing', () => {
506+
const scaffold = p.rows.filter(
507+
(r) => r.header && /(?:Table|LineItems)$/.test(r.element.qname.split(':').pop() ?? '')
508+
)
509+
expect(scaffold).toHaveLength(0)
510+
})
511+
512+
it('keeps the real section header and the concepts below it', () => {
513+
expect(rowByKey(p, SEC_ABS)?.header).toBe(true)
514+
expect(rowByKey(p, REV)?.cells?.[0]?.value).toBe(383285000000)
515+
})
516+
517+
it('promotes the concepts to the scaffold’s depth instead of over-indenting', () => {
518+
// Section header at 0; the two scaffold levels are skipped, so Revenues lands
519+
// at depth 1 — not depth 3 (header + [Table] + [Line Items]).
520+
expect(rowByKey(p, SEC_ABS)?.depth).toBe(0)
521+
expect(rowByKey(p, REV)?.depth).toBe(1)
522+
})
523+
})
524+
447525
describe('buildPivot — a member-only concept gets a heading row (dims as rows)', () => {
448526
// A detail disclosure: a concept reported only per-member, with no consolidated
449527
// total. Its member rows must not be orphaned "member-only" labels — a valueless

0 commit comments

Comments
 (0)