Skip to content

Commit 27fff11

Browse files
authored
Merge pull request #19 from RoboFinSystems/bugfix/hide-hypercube-scaffolding-rows
fix: hide XBRL hypercube scaffolding rows (Table / Line Items)
2 parents 0773ecf + a426d27 commit 27fff11

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)