Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/v0.4.0.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# v0.4.0

## New Features
- XBRL data viewer: click any XBRL document to see formatted financial reports with tabbed navigation
- XBRL reports display human-readable names parsed from FilingSummary.xml
- XBRL badge on XBRL documents in the sidebar

## Improvements
- Refactored filing search into sub-components: result row, status bar, query item, query editor
- Filing result row redesigned with CSS grid layout: ticker, title, description, tags, and date
Expand Down
8 changes: 8 additions & 0 deletions src/app/components/welcome-modal/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.4.0',
sections: [
{
title: 'New Features',
items: [
'XBRL data viewer: click any XBRL document to see formatted financial reports with tabbed navigation',
'XBRL reports display human-readable names parsed from FilingSummary.xml',
'XBRL badge on XBRL documents in the sidebar',
],
},
{
title: 'Improvements',
items: [
Expand Down
13 changes: 13 additions & 0 deletions src/app/core/models/document.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,16 @@ export interface DocumentImageOptions {
page?: number;
}

/**
* A report entry parsed from FilingSummary.xml in XBRL filings.
*/
export interface XbrlReport {
shortName: string;
longName: string;
htmlFileName: string;
menuCategory: string;
position: number;
reportType: string;
role?: string;
}

12 changes: 12 additions & 0 deletions src/app/core/services/document.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ export class DocumentService {
return this.http.get<DocumentMeta>(`${this.baseUrl}/${accessionNumber}/meta`);
}

/**
* Get a document's content as text.
* Useful for fetching XML or HTML content for client-side parsing.
* @param accessionNumber - The document's accession number
* @param seq - The sequence number of the file
*/
getDocumentText(accessionNumber: string, seq: number | string): Observable<string> {
return this.http.get(`${this.baseUrl}/${accessionNumber}/${seq}`, {
responseType: 'text',
});
}

/**
* Get context information for a document fragment.
* @param accessionNumber - The document's accession number
Expand Down
55 changes: 55 additions & 0 deletions src/app/core/services/xbrl-parser.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { XbrlReport } from '../models/document.model';

/**
* Parses FilingSummary.xml from SEC XBRL filings
* into structured report metadata.
*/
@Injectable({
providedIn: 'root',
})
export class XbrlParserService {
/**
* Parse FilingSummary.xml content into an array of XBRL reports.
* Filters out the "All Reports" book entry and sorts by position.
*/
parseFilingSummary(xml: string): XbrlReport[] {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');

const parseError = doc.querySelector('parsererror');
if (parseError) {
console.error('Failed to parse FilingSummary.xml:', parseError.textContent);
return [];
}

const reportElements = doc.querySelectorAll('MyReports > Report');
const reports: XbrlReport[] = [];

reportElements.forEach((el) => {
const reportType = this.getTextContent(el, 'ReportType');
const htmlFileName = this.getTextContent(el, 'HtmlFileName');

// Skip the "All Reports" book entry and reports without HTML files
if (reportType === 'Book' || !htmlFileName) {
return;
}

reports.push({
shortName: this.getTextContent(el, 'ShortName') || htmlFileName,
longName: this.getTextContent(el, 'LongName') || '',
htmlFileName,
menuCategory: this.getTextContent(el, 'MenuCategory') || '',
position: parseInt(this.getTextContent(el, 'Position') || '0', 10),
reportType,
role: this.getTextContent(el, 'Role') || undefined,
});
});

return reports.sort((a, b) => a.position - b.position);
}

private getTextContent(parent: Element, tagName: string): string {
return parent.querySelector(tagName)?.textContent?.trim() || '';
}
}
137 changes: 137 additions & 0 deletions src/app/features/document/document-viewer.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,140 @@ iframe {
main {
position: relative;
}

/* ============================================
XBRL Report Viewer Styles
============================================
These target injected HTML from SEC R*.htm files.
Using ::ng-deep because [innerHTML] content is
outside Angular's view encapsulation.
============================================ */

:host ::ng-deep .xbrl-content {
color: #cccccc;
font-size: 13px;
line-height: 1.5;
}

:host ::ng-deep .xbrl-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
background: #1e1e1e;
border: 1px solid #3c3c3c;
}

:host ::ng-deep .xbrl-content th {
background: #252526;
color: #cccccc;
font-weight: 600;
text-align: left;
padding: 8px 12px;
border: 1px solid #3c3c3c;
font-size: 12px;
white-space: nowrap;
}

:host ::ng-deep .xbrl-content td {
padding: 6px 12px;
border: 1px solid #2d2d2d;
font-size: 12px;
vertical-align: top;
}

:host ::ng-deep .xbrl-content tr:nth-child(even) {
background: #252526;
}

:host ::ng-deep .xbrl-content tr:nth-child(odd) {
background: #1e1e1e;
}

:host ::ng-deep .xbrl-content tr:hover {
background: #2a2d2e;
}

/* Numeric values — right-align */
:host ::ng-deep .xbrl-content td.nump,
:host ::ng-deep .xbrl-content td.num {
text-align: right;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
color: #4ec9b0;
}

/* Negative values */
:host ::ng-deep .xbrl-content td.numn {
text-align: right;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
color: #f14c4c;
}

/* Row labels */
:host ::ng-deep .xbrl-content td.pl,
:host ::ng-deep .xbrl-content td.text {
color: #cccccc;
}

/* Bold/header rows within tables */
:host ::ng-deep .xbrl-content tr.rh td,
:host ::ng-deep .xbrl-content td strong,
:host ::ng-deep .xbrl-content td b {
color: #ffffff;
font-weight: 600;
}

/* Links in XBRL content */
:host ::ng-deep .xbrl-content a {
color: #3794ff;
text-decoration: none;
}

:host ::ng-deep .xbrl-content a:hover {
text-decoration: underline;
}

/* Override any inline styles from SEC HTML */
:host ::ng-deep .xbrl-content * {
background-color: transparent !important;
color: inherit;
}

:host ::ng-deep .xbrl-content table,
:host ::ng-deep .xbrl-content th,
:host ::ng-deep .xbrl-content td {
background-color: revert !important;
}

/* Report title styling */
:host ::ng-deep .xbrl-content h1,
:host ::ng-deep .xbrl-content h2,
:host ::ng-deep .xbrl-content h3 {
color: #ffffff;
margin-bottom: 12px;
}

/* Hide any embedded scripts or style tags */
:host ::ng-deep .xbrl-content style,
:host ::ng-deep .xbrl-content script {
display: none;
}

/* Scrollable table wrapper for wide tables */
:host ::ng-deep .xbrl-content {
overflow-x: auto;
}

/* Report tab scrollbar styling */
.xbrl-content::-webkit-scrollbar {
height: 6px;
width: 6px;
}

.xbrl-content::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 3px;
}

.xbrl-content::-webkit-scrollbar-track {
background: transparent;
}
52 changes: 48 additions & 4 deletions src/app/features/document/document-viewer.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,20 @@
<ng-icon [name]="getDocIcon(doc)" class="text-[14px] text-[#858585] flex-none" />
<div class="flex-1 min-w-0">
<div class="text-[12px] truncate" [class.text-white]="selectedDocument()?.sequence === doc.sequence">
{{ doc.filename }}
{{ getXbrlReportName(doc) || doc.filename }}
</div>
@if (doc.description) {
@if (getXbrlReportName(doc)) {
<div class="text-[10px] text-[#6e6e6e] truncate">{{ doc.filename }}</div>
} @else if (doc.description) {
<div class="text-[10px] text-[#6e6e6e] truncate">{{ doc.description }}</div>
}
</div>
@if (doc.sequence === 1) {
<span class="text-[9px] px-1 bg-[#0e639c] text-white flex-none">PRIMARY</span>
}
@if (isXbrlDocument(doc)) {
<span class="text-[9px] px-1 bg-[#4d3d0a] text-[#d4a843] flex-none">XBRL</span>
}
</button>
}
</div>
Expand Down Expand Up @@ -137,6 +142,40 @@
<p class="text-[15px]">Failed to load filing</p>
<p class="text-[13px] mt-1 text-[#6e6e6e]">{{ error() }}</p>
</div>
} @else if (xbrlMode()) {
<!-- XBRL Report Viewer -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Report tabs bar -->
<div class="flex-none flex items-center gap-1 px-2 py-1.5 bg-[#252526] border-b border-[#3c3c3c] overflow-x-auto">
@for (report of xbrlReports(); track report.position) {
<button
(click)="selectXbrlReport(report)"
class="px-3 py-1 text-[12px] whitespace-nowrap rounded-sm transition-colors"
[class]="selectedXbrlReport()?.htmlFileName === report.htmlFileName
? 'bg-[#094771] text-white'
: 'text-[#858585] hover:text-[#cccccc] hover:bg-[#3c3c3c]'"
>
{{ report.shortName }}
</button>
}
</div>

<!-- Report content -->
<div class="flex-1 overflow-auto">
@if (xbrlLoading()) {
<div class="flex items-center justify-center h-full">
<ng-icon name="lucideLoader2" class="text-[32px] text-[#858585] animate-spin" />
</div>
} @else if (xbrlReportHtml()) {
<div class="xbrl-content p-4" [innerHTML]="xbrlReportHtml()"></div>
} @else {
<div class="flex-1 flex flex-col items-center justify-center h-full text-[#6e6e6e]">
<ng-icon name="lucideTable" class="text-[48px] mb-3 opacity-30" />
<p class="text-[13px]">No XBRL reports found</p>
</div>
}
</div>
</div>
} @else if (iframeSrc()) {
<!-- Document Frame Container -->
<div class="flex-1 relative overflow-auto bg-white">
Expand All @@ -146,7 +185,7 @@
<ng-icon name="lucideLoader2" class="text-[32px] text-[#858585] animate-spin" />
</div>
}

<!-- Iframe -->
<iframe
[src]="iframeSrc()"
Expand All @@ -173,7 +212,12 @@
<!-- Status Bar -->
<footer class="flex-none h-6 flex items-center justify-between px-3 bg-[#007acc] text-white text-[12px]">
<div class="flex items-center gap-3">
@if (selectedDocument()) {
@if (xbrlMode() && selectedXbrlReport()) {
<span>{{ selectedXbrlReport()!.shortName }}</span>
@if (xbrlLoading()) {
<span class="text-blue-200">· Loading...</span>
}
} @else if (selectedDocument()) {
<span>{{ selectedDocument()!.filename }}</span>
@if (iframeLoading()) {
<span class="text-blue-200">· Loading...</span>
Expand Down
Loading