From e2ce8153dcdb87235e91f5ae05579532f880636e Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Fri, 7 Aug 2026 14:00:00 +0530 Subject: [PATCH 01/13] Add test suite/CI, on-demand company import, and real trend charts - backend/tests/ (pytest) covering extraction heuristics, diff/metrics functions, and every API route via TestClient against a throwaway DB; .github/workflows/tests.yml runs it on push/PR - backend/app/acquisition.py: import any ticker from SEC EDGAR on demand, reusing ingest.py's existing fetch/extract/diff pipeline; new /api/companies/search and /api/companies/{ticker}/import endpoints; frontend/js/home.js offers this when a search has no local match - frontend/js/chart.js: dependency-free SVG trend charts (similarity, Fog index) across every filing a company has, fed entirely by the analyst_metrics the backend already computes -- no fabricated data - README updated to describe the test suite and on-demand import Built on the rebuild branch off main, which was already the clean pre-dashboard-pivot state; ayush and main are untouched. --- .github/workflows/tests.yml | 18 +++++ README.md | 54 +++++++++++---- backend/app/acquisition.py | 76 ++++++++++++++++++++ backend/app/main.py | 74 +++++++++++++++++++- backend/tests/__init__.py | 0 backend/tests/conftest.py | 79 +++++++++++++++++++++ backend/tests/test_api.py | 127 ++++++++++++++++++++++++++++++++++ backend/tests/test_diffing.py | 80 +++++++++++++++++++++ backend/tests/test_extract.py | 83 ++++++++++++++++++++++ frontend/css/style.css | 107 ++++++++++++++++++++++++++++ frontend/js/api.js | 7 +- frontend/js/chart.js | 104 ++++++++++++++++++++++++++++ frontend/js/company.js | 26 +++++++ frontend/js/home.js | 57 ++++++++++++++- requirements.txt | 1 + 15 files changed, 875 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 backend/app/acquisition.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_diffing.py create mode 100644 backend/tests/test_extract.py create mode 100644 frontend/js/chart.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ae6e5af --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,18 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run tests + run: pytest backend/tests/ -v diff --git a/README.md b/README.md index e6d3309..fe44ebc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ SEC EDGAR 10-K BYOK optional LLM layer License: MIT + pytest test suite

A public, browsable dashboard that shows exactly what changed in a company's SEC risk-factor disclosures between consecutive filings — a GitHub pull-request diff, applied to prose.

@@ -32,7 +33,12 @@ strip gives an at-a-glance sentence count of what changed before you read a word itself, a section-jump sidebar lets you skip straight to a specific risk topic instead of scrolling a 40-paragraph document, and an analyst-metrics panel surfaces three deterministic, citation-backed disclosure-analysis figures (textual similarity, Fog readability, section length) alongside the -qualitative diff. +qualitative diff, plotted as a trend across every filing the company has, not just the two periods +currently selected. + +The pre-seeded universe (~38 well-known tickers) isn't a hard limit: searching a ticker that isn't +indexed yet offers to import it live, straight from SEC EDGAR, using the exact same extraction/diff +pipeline as the offline batch job. This is a standalone project built to demonstrate four things: **SEC EDGAR filing ingestion and section-level text extraction** (locating a specific, inconsistently-formatted legal section @@ -51,6 +57,7 @@ handling** for the one optional AI feature in the app. - [Why this exists](#why-this-exists) - [Architecture](#architecture) - [Installation / running locally](#installation--running-locally) +- [Testing](#testing) - [Quickstart](#quickstart) - [How the diffing works](#how-the-diffing-works) - [Data handling & privacy](#data-handling--privacy) @@ -109,8 +116,7 @@ passthrough endpoint or proxy — the request goes straight from your browser to ## Installation / running locally -Requires Python 3.9+. No Docker, no build step for the frontend, no separate database server, no -automated test suite (by design — this project puts that time into UI polish instead). +Requires Python 3.9+. No Docker, no build step for the frontend, no separate database server. ```bash git clone https://github.com/divyaanshkumar24/SEC-Filing-Risk-Factor-Diff-Tracker.git @@ -141,25 +147,42 @@ No restart, no config file — the key lives only in that browser tab for that s database from scratch, re-fetching current filings. This project intentionally does not auto-refresh on a schedule — re-run it whenever you want an updated snapshot. +## Testing + +```bash +pytest backend/tests/ -v +``` + +Covers the Item 1A section-isolation heuristic against synthetic filing fixtures +([`test_extract.py`](backend/tests/test_extract.py)), the diff/analyst-metrics functions against +hand-computed inputs ([`test_diffing.py`](backend/tests/test_diffing.py)), and every route in +`main.py` via FastAPI's `TestClient` against a throwaway seeded SQLite DB +([`test_api.py`](backend/tests/test_api.py)) — nothing touches the real `tracker.db`. Runs in CI on +every push/PR ([`.github/workflows/tests.yml`](.github/workflows/tests.yml)). + ## Quickstart 1. Open the homepage — a "biggest recent changes" strip highlights the companies with the largest sentence-level change in their latest comparison, below which is a searchable, sector-filterable, sortable (A–Z or biggest change) grid of the whole company universe. Each card shows ticker, name, sector, and a "+N / −M" badge for its most recent filing-over-filing change. -2. Click a company card to open its detail page. -3. Use the two period dropdowns (or click a dot on the timeline) to pick which two filings to +2. Searching a ticker or name with no local match offers to import it live from SEC EDGAR — the + same fetch/extract/diff pipeline as the offline ingestion, just scoped to one company and run + on request. +3. Click a company card to open its detail page. Two trend charts show textual similarity and Fog + readability across every filing pair the company has, not just the pair currently selected. +4. Use the two period dropdowns (or click a dot on the timeline) to pick which two filings to compare — any two, not just consecutive ones. -4. Read the diff: added paragraphs highlighted in blue with a `+`, removed paragraphs in amber +5. Read the diff: added paragraphs highlighted in blue with a `+`, removed paragraphs in amber with a `−` and a strikethrough, unchanged paragraphs in neutral grey. The summary strip above it gives the sentence-level added/removed counts and links to both original filings on SEC EDGAR. -5. Use the **Jump to section** sidebar to skip directly to a specific risk topic (e.g. "Macroeconomic +6. Use the **Jump to section** sidebar to skip directly to a specific risk topic (e.g. "Macroeconomic and Industry Risks") instead of scrolling the whole document — it highlights your current section as you scroll, and the **Analyst metrics** panel above it gives you textual similarity, Fog readability, and section length for this comparison (see [How the diffing works](#how-the-diffing-works)). -6. If you've entered an API key, a plain-English AI summary of the change appears in its own panel +7. If you've entered an API key, a plain-English AI summary of the change appears in its own panel below the summary strip. -7. Toggle light/dark theme from the circular button in the top-right at any time. +8. Toggle light/dark theme from the circular button in the top-right at any time. ## How the diffing works @@ -258,7 +281,9 @@ similarity score or a rising Fog index is a prompt to go read the diff, not a co | `GET` | `/api/health` | Liveness check + whether the database has been populated | | `GET` | `/api/meta` | Dataset "data as of" date and ingested company count | | `GET` | `/api/companies` | List every company with >=2 ingested filings, with a latest-change summary badge | -| `GET` | `/api/companies/{ticker}` | Company detail: all ingested filing periods with dates and source URLs | +| `GET` | `/api/companies/search?q=` | Search locally-indexed companies plus SEC's full ticker map, so a match that isn't indexed yet can be offered for import | +| `POST` | `/api/companies/{ticker}/import` | Fetch, extract, and diff every available 10-K for a ticker outside the pre-seeded universe | +| `GET` | `/api/companies/{ticker}` | Company detail: all ingested filing periods with dates/source URLs, plus a `trend` array (similarity/Fog/word-count per consecutive filing pair) for the trend charts | | `GET` | `/api/companies/{ticker}/diff?from_id=&to_id=` | Precomputed paragraph-level diff (each chunk flagged `heading: true/false` for the section nav) + summary + `analyst_metrics` between two periods | This backend never accepts or forwards an Anthropic API key — see @@ -284,14 +309,19 @@ sqlite · open-source** JSON API. No ORM — plain `sqlite3` with hand-written, parameterized queries. - **Ingestion:** a standalone script ([`backend/app/ingest.py`](backend/app/ingest.py)) that pulls SEC EDGAR's submissions API and each filing's primary HTML document, run offline — never in the - live request path. + live request path. [`backend/app/acquisition.py`](backend/app/acquisition.py) reuses the same + fetch/extract/diff functions to scope that pipeline to a single on-demand ticker, callable from + the live API. - **Extraction & diffing:** dependency-light Python ([`backend/app/extract.py`](backend/app/extract.py), [`backend/app/diffing.py`](backend/app/diffing.py)) using BeautifulSoup for HTML-to-text and the standard-library `difflib` for comparison — no NLP model involved. - **Frontend:** vanilla HTML/CSS/JS with ES modules, no build step, no framework — a hash-based router between the browse grid and company detail views, with a serif reading typeface and a - colorblind-conscious diff palette. + colorblind-conscious diff palette. The trend charts ([`frontend/js/chart.js`](frontend/js/chart.js)) + are hand-rolled themed SVG, not an external charting library — there are never more than a + handful of data points, and inline SVG lets the marks follow the app's own CSS custom properties + (and light/dark theme) for free. - **Database:** SQLite — a single file, no server process, trivially inspectable with any SQLite client. diff --git a/backend/app/acquisition.py b/backend/app/acquisition.py new file mode 100644 index 0000000..ca37f2a --- /dev/null +++ b/backend/app/acquisition.py @@ -0,0 +1,76 @@ +"""On-demand import of a single company outside the pre-seeded universe. + +Reuses the exact same fetch/extract/diff pipeline as the offline batch job +in ingest.py -- this just scopes it to one ticker and is callable from a +live API request. No separate logic, no embeddings, nothing invented. +""" + +from .companies import SECTORS +from .ingest import ( + TICKER_MAP_URL, + _get, + compute_diffs_for_ticker, + ingest_ticker, +) + +_ticker_rows_cache = None + + +def _ticker_rows(): + """SEC's full ticker->CIK->title map, cached for the life of the process + (it changes rarely and this endpoint may be hit repeatedly per search).""" + global _ticker_rows_cache + if _ticker_rows_cache is None: + _ticker_rows_cache = list(_get(TICKER_MAP_URL).json().values()) + return _ticker_rows_cache + + +def resolve_ticker(ticker: str): + """Return (cik10, display_name) for a ticker via SEC's authoritative + map, or (None, None) if it's not a filer SEC knows about.""" + ticker = ticker.upper() + for row in _ticker_rows(): + if row["ticker"].upper() == ticker: + return str(row["cik_str"]).zfill(10), row["title"] + return None, None + + +def search_tickers(query: str, limit: int = 8): + query = query.strip().upper() + if not query: + return [] + results = [] + for row in _ticker_rows(): + ticker = row["ticker"].upper() + title = row["title"] + if query in ticker or query.lower() in title.lower(): + results.append({"ticker": ticker, "name": title}) + if len(results) >= limit: + break + return results + + +def import_company(conn, ticker: str) -> int: + """Fetch, extract, and diff every available 10-K for `ticker`, storing + the company row if it's new. Returns the number of usable filings + stored. Raises ValueError on a ticker SEC doesn't recognize or one with + fewer than 2 usable Item 1A sections (too few to diff).""" + ticker = ticker.upper() + cik10, name = resolve_ticker(ticker) + if not cik10: + raise ValueError(f"'{ticker}' was not found in SEC's ticker map") + + conn.execute( + "INSERT OR IGNORE INTO companies (ticker, name, cik, sector) VALUES (?, ?, ?, ?)", + (ticker, name, cik10, SECTORS.get(ticker, "Other")), + ) + conn.commit() + + stored = ingest_ticker(conn, ticker, cik10) + if stored < 2: + raise ValueError( + f"Only {stored} usable 10-K filing(s) found for '{ticker}' -- " + "need at least 2 to compute a diff" + ) + compute_diffs_for_ticker(conn, ticker) + return stored diff --git a/backend/app/main.py b/backend/app/main.py index b7aa425..14fe093 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from . import db +from . import acquisition, db FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" @@ -23,7 +23,7 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -83,6 +83,54 @@ def list_companies(): return {"companies": results} +@app.get("/api/companies/search") +def search_companies(q: str = ""): + """Search both the locally-indexed universe and SEC's full ticker map, + so the frontend can offer to import a match that isn't indexed yet.""" + q_norm = q.strip().upper() + if not q_norm: + return {"results": []} + + conn = db.get_conn() + local_rows = conn.execute( + """SELECT c.ticker, c.name, c.sector, COUNT(f.id) as filing_count + FROM companies c JOIN filings f ON f.ticker = c.ticker + WHERE UPPER(c.ticker) LIKE ? OR UPPER(c.name) LIKE ? + GROUP BY c.ticker HAVING COUNT(f.id) >= 2 + ORDER BY c.ticker""", + (f"%{q_norm}%", f"%{q_norm}%"), + ).fetchall() + conn.close() + + local_tickers = {r["ticker"] for r in local_rows} + results = [ + {"ticker": r["ticker"], "name": r["name"], "sector": r["sector"], "is_indexed": True} + for r in local_rows + ] + + try: + for cand in acquisition.search_tickers(q_norm, limit=8): + if cand["ticker"] not in local_tickers: + results.append({"ticker": cand["ticker"], "name": cand["name"], "sector": None, "is_indexed": False}) + except Exception: + pass # SEC lookup is best-effort; local results still return + + return {"results": results[:12]} + + +@app.post("/api/companies/{ticker}/import") +def import_company(ticker: str): + ticker = ticker.upper() + conn = db.get_conn() + try: + stored = acquisition.import_company(conn, ticker) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + finally: + conn.close() + return {"ticker": ticker, "filings_stored": stored} + + @app.get("/api/companies/{ticker}") def company_detail(ticker: str): ticker = ticker.upper() @@ -97,11 +145,30 @@ def company_detail(ticker: str): FROM filings WHERE ticker = ? ORDER BY filing_date ASC""", (ticker,), ).fetchall() - conn.close() if len(filings) < 2: + conn.close() raise HTTPException(status_code=404, detail="Not enough filings ingested for this company yet") + # Consecutive-pair diffs only, in chronological order, for the trend chart -- + # every point here is a real precomputed analyst_metrics row, nothing derived. + trend = [] + for i in range(len(filings) - 1): + from_f, to_f = filings[i], filings[i + 1] + diff_row = conn.execute( + """SELECT similarity_score, fog_index_to, word_count_to + FROM diffs WHERE from_filing_id = ? AND to_filing_id = ?""", + (from_f["id"], to_f["id"]), + ).fetchone() + if diff_row: + trend.append({ + "label": (to_f["period_of_report"] or to_f["filing_date"])[:4], + "similarity_score": diff_row["similarity_score"], + "fog_index": diff_row["fog_index_to"], + "word_count": diff_row["word_count_to"], + }) + conn.close() + return { "ticker": company["ticker"], "name": company["name"], @@ -119,6 +186,7 @@ def company_detail(ticker: str): } for f in filings ], + "trend": trend, } diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3732d5b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared fixtures: every test runs against a throwaway SQLite DB, never the +real tracker.db, via monkeypatching db.DB_PATH before anything opens a +connection. +""" + +import json + +import pytest + +from backend.app import db +from backend.app.diffing import compute_analyst_metrics, diff_paragraphs + + +@pytest.fixture +def test_db(tmp_path, monkeypatch): + db_path = tmp_path / "test_tracker.db" + monkeypatch.setattr(db, "DB_PATH", db_path) + conn = db.init_db(reset=True) + conn.close() + yield db_path + + +def seed_company_with_diff(conn, ticker="ACME", sector="Technology"): + """Insert one company with two filings and their precomputed diff -- + the minimal shape every /api/companies/* endpoint expects.""" + conn.execute( + "INSERT INTO companies (ticker, name, cik, sector) VALUES (?, ?, ?, ?)", + (ticker, f"{ticker} Corp", "0000000001", sector), + ) + + paras_a = [ + "Risk Factors", + "Our business depends on continued demand for our products.", + "We face significant competition in our core markets.", + ] + paras_b = [ + "Risk Factors", + "Our business depends on continued demand for our products.", + "We face significant competition in our core markets, including from new entrants.", + "Cybersecurity incidents could disrupt our operations.", + ] + text_a = "\n\n".join(paras_a) + text_b = "\n\n".join(paras_b) + + from_id = conn.execute( + """INSERT INTO filings + (ticker, form_type, fiscal_year, period_of_report, filing_date, + accession_no, source_url, risk_factor_text, char_count, paragraph_count) + VALUES (?, '10-K', '2022', '2022-12-31', '2023-01-15', 'acc-1', 'https://example.com/1', ?, ?, ?)""", + (ticker, text_a, len(text_a), len(paras_a)), + ).lastrowid + to_id = conn.execute( + """INSERT INTO filings + (ticker, form_type, fiscal_year, period_of_report, filing_date, + accession_no, source_url, risk_factor_text, char_count, paragraph_count) + VALUES (?, '10-K', '2023', '2023-12-31', '2024-01-15', 'acc-2', 'https://example.com/2', ?, ?, ?)""", + (ticker, text_b, len(text_b), len(paras_b)), + ).lastrowid + + chunks, summary = diff_paragraphs(paras_a, paras_b) + metrics = compute_analyst_metrics(text_a, text_b) + conn.execute( + """INSERT INTO diffs + (ticker, from_filing_id, to_filing_id, added_chunks, removed_chunks, + unchanged_chunks, added_sentences, removed_sentences, chunks_json, + similarity_score, fog_index_from, fog_index_to, + word_count_from, word_count_to, word_count_change_pct) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + ticker, from_id, to_id, + summary["added_chunks"], summary["removed_chunks"], summary["unchanged_chunks"], + summary["added_sentences"], summary["removed_sentences"], + json.dumps(chunks), + metrics["similarity_score"], metrics["fog_index_from"], metrics["fog_index_to"], + metrics["word_count_from"], metrics["word_count_to"], metrics["word_count_change_pct"], + ), + ) + conn.commit() + return from_id, to_id diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..4c7dc0c --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,127 @@ +"""Smoke tests over every route in main.py, via FastAPI's TestClient against +a throwaway seeded DB. This is the layer that would have caught a broken +import (like the old `from llm_local import ...` bug) or a missing-column +regression immediately -- every route must actually return, not just parse. +""" + +from fastapi.testclient import TestClient + +from backend.app.main import app +from backend.tests.conftest import seed_company_with_diff +from backend.app import db + + +def _client(): + return TestClient(app) + + +def test_health(test_db): + client = _client() + resp = client.get("/api/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["database_populated"] is True + + +def test_meta_with_no_data(test_db): + client = _client() + resp = client.get("/api/meta") + assert resp.status_code == 200 + assert resp.json()["company_count"] == 0 + + +def test_list_companies_and_detail_and_diff(test_db): + conn = db.get_conn() + from_id, to_id = seed_company_with_diff(conn) + conn.close() + + client = _client() + + resp = client.get("/api/companies") + assert resp.status_code == 200 + companies = resp.json()["companies"] + assert len(companies) == 1 + assert companies[0]["ticker"] == "ACME" + assert companies[0]["latest_change"]["added"] >= 1 + + resp = client.get("/api/companies/ACME") + assert resp.status_code == 200 + detail = resp.json() + assert detail["ticker"] == "ACME" + assert len(detail["periods"]) == 2 + assert len(detail["trend"]) == 1 + assert detail["trend"][0]["similarity_score"] is not None + + resp = client.get(f"/api/companies/ACME/diff?from_id={from_id}&to_id={to_id}") + assert resp.status_code == 200 + diff = resp.json() + assert diff["summary"]["added_chunks"] >= 1 + assert "analyst_metrics" in diff + assert "similarity_score" in diff["analyst_metrics"] + assert isinstance(diff["chunks"], list) + # Guard against the fabricated-data regression this whole cleanup was + # about: no score/verdict/AI-confidence field should ever appear here. + assert "risk_heatmap" not in diff + assert "top_important_changes" not in diff + assert "ai_confidence" not in diff + + +def test_company_detail_unknown_ticker_is_404(test_db): + resp = _client().get("/api/companies/NOPE") + assert resp.status_code == 404 + + +def test_company_detail_with_fewer_than_two_filings_is_404(test_db): + conn = db.get_conn() + conn.execute( + "INSERT INTO companies (ticker, name, cik, sector) VALUES ('SOLO', 'Solo Corp', '1', 'Technology')" + ) + conn.commit() + conn.close() + resp = _client().get("/api/companies/SOLO") + assert resp.status_code == 404 + + +def test_diff_unknown_period_pair_is_404(test_db): + conn = db.get_conn() + seed_company_with_diff(conn) + conn.close() + resp = _client().get("/api/companies/ACME/diff?from_id=9999&to_id=9998") + assert resp.status_code == 404 + + +def test_search_empty_query_returns_empty(test_db): + resp = _client().get("/api/companies/search?q=") + assert resp.status_code == 200 + assert resp.json()["results"] == [] + + +def test_search_matches_local_company(test_db, monkeypatch): + from backend.app import acquisition + + # Local match is what's under test here -- stub out the SEC lookup so + # this test doesn't depend on network access. + monkeypatch.setattr(acquisition, "search_tickers", lambda q, limit=8: []) + + conn = db.get_conn() + seed_company_with_diff(conn) + conn.close() + resp = _client().get("/api/companies/search?q=ACM") + assert resp.status_code == 200 + results = resp.json()["results"] + assert any(r["ticker"] == "ACME" and r["is_indexed"] for r in results) + + +def test_import_unknown_ticker_returns_422(test_db, monkeypatch): + from backend.app import acquisition + + monkeypatch.setattr(acquisition, "resolve_ticker", lambda t: (None, None)) + resp = _client().post("/api/companies/BOGUS123/import") + assert resp.status_code == 422 + + +def test_index_serves_frontend(test_db): + resp = _client().get("/") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] diff --git a/backend/tests/test_diffing.py b/backend/tests/test_diffing.py new file mode 100644 index 0000000..238f2ff --- /dev/null +++ b/backend/tests/test_diffing.py @@ -0,0 +1,80 @@ +import math + +from backend.app.diffing import ( + compute_analyst_metrics, + cosine_similarity_score, + diff_paragraphs, + fog_index, +) + + +def test_diff_paragraphs_classifies_unchanged_added_removed(): + a = ["Alpha paragraph.", "Beta paragraph.", "Gamma paragraph."] + b = ["Alpha paragraph.", "Gamma paragraph.", "Delta paragraph."] + chunks, summary = diff_paragraphs(a, b) + + types = {c["text"]: c["type"] for c in chunks} + assert types["Alpha paragraph."] == "unchanged" + assert types["Beta paragraph."] == "removed" + assert types["Delta paragraph."] == "added" + assert summary["added_chunks"] == 1 + assert summary["removed_chunks"] == 1 + assert summary["unchanged_chunks"] == 2 # Alpha + Gamma + + +def test_diff_paragraphs_replace_renders_as_remove_then_insert(): + a = ["We face intense competition."] + b = ["We face intense competition from new market entrants."] + chunks, summary = diff_paragraphs(a, b) + + assert [c["type"] for c in chunks] == ["removed", "added"] + assert summary["removed_chunks"] == 1 + assert summary["added_chunks"] == 1 + + +def test_diff_paragraphs_identical_input_is_all_unchanged(): + a = ["One.", "Two.", "Three."] + chunks, summary = diff_paragraphs(a, list(a)) + assert all(c["type"] == "unchanged" for c in chunks) + assert summary["added_chunks"] == 0 + assert summary["removed_chunks"] == 0 + assert summary["unchanged_chunks"] == 3 + + +def test_cosine_similarity_identical_text_is_100(): + text = "Our business depends on continued demand for our products and services." + assert cosine_similarity_score(text, text) == 100.0 + + +def test_cosine_similarity_disjoint_text_is_zero(): + assert cosine_similarity_score("apple banana cherry", "xylophone zeppelin quokka") == 0.0 + + +def test_cosine_similarity_empty_text_is_zero(): + assert cosine_similarity_score("", "some text here") == 0.0 + assert cosine_similarity_score("some text here", "") == 0.0 + + +def test_fog_index_higher_for_longer_more_complex_sentences(): + simple = "We are a company. We sell goods. We make money." + complex_text = ( + "Our multinational organization systematically evaluates macroeconomic " + "considerations affecting operational profitability across jurisdictions." + ) + simple_fog = fog_index(simple) + complex_fog = fog_index(complex_text) + assert simple_fog is not None and complex_fog is not None + assert complex_fog > simple_fog + + +def test_fog_index_empty_text_returns_none(): + assert fog_index("") is None + + +def test_compute_analyst_metrics_word_count_change_pct(): + a = "one two three four" + b = "one two three four five six" + metrics = compute_analyst_metrics(a, b) + assert metrics["word_count_from"] == 4 + assert metrics["word_count_to"] == 6 + assert math.isclose(metrics["word_count_change_pct"], 50.0, abs_tol=0.1) diff --git a/backend/tests/test_extract.py b/backend/tests/test_extract.py new file mode 100644 index 0000000..5ec5a2c --- /dev/null +++ b/backend/tests/test_extract.py @@ -0,0 +1,83 @@ +from backend.app.extract import extract_item_1a, html_to_text, is_heading_paragraph, split_paragraphs + +FILLER = "This is a sentence about our business operations and market conditions. " * 6 + + +def _standalone_heading_doc(): + body_lines = [FILLER for _ in range(30)] + return "\n".join([ + "Item 1A. Risk Factors.", + *body_lines, + "Item 1B. Unresolved Staff Comments.", + "Nothing to report.", + ]) + + +def test_standalone_heading_is_isolated(): + section = extract_item_1a(_standalone_heading_doc()) + assert section is not None + assert "Item 1A" not in section # heading line itself is dropped + assert "Item 1B" not in section # end boundary excluded + assert FILLER.strip() in section + + +def test_toc_entry_is_not_mistaken_for_the_real_heading(): + # A table-of-contents entry sits right next to the *next* TOC entry -- + # too close together to be real prose -- so it must be skipped in favor + # of the real heading later in the document. + toc = "\n".join([ + "Item 1A. Risk Factors.", + "Item 1B. Unresolved Staff Comments.", + ]) + real_section = _standalone_heading_doc() + doc = toc + "\n" + real_section + section = extract_item_1a(doc) + assert section is not None + assert FILLER.strip() in section + + +def test_runin_caps_heading_fallback(): + body_lines = [FILLER for _ in range(30)] + doc = "\n".join([ + "RISK FACTORS. " + FILLER, + *body_lines, + "UNRESOLVED STAFF COMMENTS.", + ]) + section = extract_item_1a(doc) + assert section is not None + assert section.startswith("RISK FACTORS.") + + +def test_no_isolable_section_returns_none(): + doc = "Just some ordinary filing prose with no recognizable Item 1A heading anywhere in it." + assert extract_item_1a(doc) is None + + +def test_short_section_is_rejected_even_if_headings_match(): + doc = "\n".join([ + "Item 1A. Risk Factors.", + *(["A short line."] * 25), + "Item 1B. Unresolved Staff Comments.", + ]) + assert extract_item_1a(doc) is None + + +def test_html_to_text_preserves_paragraph_breaks_and_drops_page_footers(): + html = "

First paragraph.

Second paragraph.

" \ + "
Acme Inc. | 2024 Form 10-K | 5
" + text = html_to_text(html) + lines = text.split("\n") + assert "First paragraph." in lines + assert "Second paragraph." in lines + assert not any("Form 10-K" in ln for ln in lines) + + +def test_is_heading_paragraph(): + assert is_heading_paragraph("Cybersecurity and Data Privacy Risks") + assert not is_heading_paragraph("We face significant competition in our core markets.") + assert not is_heading_paragraph("") + + +def test_split_paragraphs_drops_blank_lines(): + section = "First.\n\n\nSecond.\n" + assert split_paragraphs(section) == ["First.", "Second."] diff --git a/frontend/css/style.css b/frontend/css/style.css index 9ecc651..2482325 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -370,6 +370,45 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { color: var(--text-muted); } +.import-panel { + background: var(--bg-elevated); + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 16px 18px; + margin: -8px 0 40px; +} +.import-panel-status { color: var(--text-faint); font-size: 0.85rem; } +.import-panel-heading { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; + margin-bottom: 10px; +} +.import-candidates { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; +} +.import-candidate { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + text-align: left; + transition: border-color 0.15s; +} +.import-candidate:hover:not(:disabled) { border-color: var(--accent); } +.import-candidate:disabled { opacity: 0.7; cursor: wait; } +.import-candidate .ticker { font-weight: 700; font-size: 0.9rem; } +.import-candidate .name { color: var(--text-muted); font-size: 0.78rem; } +.import-candidate .import-cta { color: var(--accent); font-size: 0.76rem; font-weight: 600; margin-top: 2px; } + /* ---------- Company detail ---------- */ .back-link { font-size: 0.85rem; display: inline-block; margin: 20px 0 6px; } .company-header { @@ -511,6 +550,68 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .metric-tile .delta.up { color: var(--removed-border); } .metric-tile .delta.down { color: var(--added-border); } .metric-tile .lbl { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-faint); margin-top: 2px; } +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.trend-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-bottom: 20px; +} +.trend-chart-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px 8px; +} +.trend-chart-card h3 { + margin: 0 0 6px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.trend-chart-card .chart-wrap { position: relative; } +.trend-chart { width: 100%; height: auto; display: block; overflow: visible; } +.chart-gridline { stroke: var(--border); stroke-width: 1; } +.chart-dot { pointer-events: none; } +.chart-hit { cursor: pointer; } +.chart-hit:focus { outline: none; } +.chart-end-label { + font-size: 10px; + font-weight: 700; + fill: var(--text); + font-family: var(--font-ui); +} +.chart-x-label { + font-size: 9px; + fill: var(--text-faint); + font-family: var(--font-ui); +} +.chart-empty { color: var(--text-faint); font-size: 0.8rem; padding: 20px 0; } +.chart-tooltip { + position: absolute; + top: -6px; + transform: translate(-50%, -100%); + background: var(--text); + color: var(--bg); + font-size: 0.72rem; + font-weight: 600; + padding: 4px 8px; + border-radius: 6px; + white-space: nowrap; + pointer-events: none; +} + .metrics-panel .cite { margin-top: 12px; padding-top: 12px; @@ -695,6 +796,11 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } @media (max-width: 640px) { + /* Above 640px the sidebar wraps two panels side by side (see the 900px + breakpoint); below it, two 240px-min-width panels no longer fit next to + each other and would overflow the viewport instead of wrapping, so stack + them in a single column instead. */ + .detail-sidebar { flex-direction: column; } .company-header { flex-direction: column; } .period-picker { flex-direction: column; align-items: stretch; } .period-arrow { display: none; } @@ -705,4 +811,5 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .diff-legend { padding: 12px 16px; } .hero { padding: 28px 20px; } .hero h1 { font-size: 1.5rem; } + .trend-charts { grid-template-columns: 1fr; } } diff --git a/frontend/js/api.js b/frontend/js/api.js index 2a00084..9d082be 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -1,7 +1,7 @@ // Thin wrapper over this app's own read-only JSON API. -async function getJSON(path) { - const res = await fetch(path); +async function getJSON(path, options) { + const res = await fetch(path, options); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.detail || `Request failed (${res.status})`); @@ -14,3 +14,6 @@ export const fetchCompanies = () => getJSON("/api/companies"); export const fetchCompany = (ticker) => getJSON(`/api/companies/${encodeURIComponent(ticker)}`); export const fetchDiff = (ticker, fromId, toId) => getJSON(`/api/companies/${encodeURIComponent(ticker)}/diff?from_id=${fromId}&to_id=${toId}`); +export const searchCompanies = (q) => getJSON(`/api/companies/search?q=${encodeURIComponent(q)}`); +export const importCompany = (ticker) => + getJSON(`/api/companies/${encodeURIComponent(ticker)}/import`, { method: "POST" }); diff --git a/frontend/js/chart.js b/frontend/js/chart.js new file mode 100644 index 0000000..4b5b1a0 --- /dev/null +++ b/frontend/js/chart.js @@ -0,0 +1,104 @@ +// Small, dependency-free single-series line chart for a handful of data +// points (this app never has more than a few filing periods per company). +// No external chart library -- inline SVG themed with this app's own CSS +// custom properties, so it follows light/dark mode for free. +// +// One series per chart, on purpose: the two metrics this app plots +// (textual similarity as a %, and the Fog readability index) are on +// different scales, and a dual-axis chart is exactly the kind of thing +// that misleads a reader -- see two separate single-series charts instead. + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); +} + +const WIDTH = 420; +const HEIGHT = 110; +const PAD_LEFT = 8; +const PAD_RIGHT = 46; // room for the direct end-label +const PAD_TOP = 16; +const PAD_BOTTOM = 22; + +/** + * @param {HTMLElement} container + * @param {{label: string, value: number|null}[]} points + * @param {{seriesLabel: string, colorVar: string, format: (v:number) => string}} opts + */ +export function renderTrendChart(container, points, opts) { + const valid = points.filter((p) => p.value !== null && p.value !== undefined); + if (valid.length < 2) { + container.innerHTML = `
Not enough periods yet to chart a trend.
`; + return; + } + + const values = valid.map((p) => p.value); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + const plotW = WIDTH - PAD_LEFT - PAD_RIGHT; + const plotH = HEIGHT - PAD_TOP - PAD_BOTTOM; + + const xAt = (i) => PAD_LEFT + (valid.length === 1 ? plotW / 2 : (i / (valid.length - 1)) * plotW); + const yAt = (v) => PAD_TOP + plotH - ((v - min) / range) * plotH; + + const linePath = valid.map((p, i) => `${i === 0 ? "M" : "L"} ${xAt(i).toFixed(1)} ${yAt(p.value).toFixed(1)}`).join(" "); + const last = valid[valid.length - 1]; + const lastX = xAt(valid.length - 1); + const lastY = yAt(last.value); + + const dots = valid.map((p, i) => { + const isLast = i === valid.length - 1; + return ` + + `; + }).join(""); + + container.innerHTML = ` + + + + + ${dots} + ${escapeHtml(opts.format(last.value))} + ${valid.map((p, i) => `${escapeHtml(p.label)}`).join("")} + + + + + + + ${valid.map((p) => ``).join("")} + +
${escapeHtml(opts.seriesLabel)} by filing period
Period${escapeHtml(opts.seriesLabel)}
${escapeHtml(p.label)}${escapeHtml(opts.format(p.value))}
+ `; + + const tooltip = container.querySelector(".chart-tooltip"); + const svg = container.querySelector("svg"); + + function showTooltip(index, evt) { + const p = valid[index]; + tooltip.textContent = `${p.label}: ${opts.format(p.value)}`; + tooltip.hidden = false; + const rect = container.getBoundingClientRect(); + const svgRect = svg.getBoundingClientRect(); + const scaleX = svgRect.width / WIDTH; + const x = svgRect.left - rect.left + xAt(index) * scaleX; + tooltip.style.left = `${Math.min(Math.max(x, 30), rect.width - 30)}px`; + } + function hideTooltip() { + tooltip.hidden = true; + } + + container.querySelectorAll(".chart-hit").forEach((hit) => { + const i = Number(hit.dataset.index); + hit.addEventListener("pointerenter", (e) => showTooltip(i, e)); + hit.addEventListener("focus", (e) => showTooltip(i, e)); + hit.addEventListener("pointerleave", hideTooltip); + hit.addEventListener("blur", hideTooltip); + }); +} diff --git a/frontend/js/company.js b/frontend/js/company.js index c8c7137..944f028 100644 --- a/frontend/js/company.js +++ b/frontend/js/company.js @@ -2,6 +2,7 @@ import { fetchCompany, fetchDiff } from "./api.js"; import { getApiKey } from "./state.js"; import { summarizeDiff } from "./llm.js"; import { countUp } from "./animate.js"; +import { renderTrendChart } from "./chart.js"; function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ @@ -69,6 +70,18 @@ export async function renderCompany(container, ticker) {
+ ${company.trend && company.trend.length >= 2 ? ` +
+
+

Textual similarity, filing over filing

+
+
+
+

Fog readability, filing over filing

+
+
+
` : ""} +
Loading diff…
@@ -79,6 +92,19 @@ export async function renderCompany(container, ticker) { const timeline = document.getElementById("timeline"); const diffRegion = document.getElementById("diff-region"); + if (company.trend && company.trend.length >= 2) { + renderTrendChart( + document.getElementById("trend-similarity"), + company.trend.map((t) => ({ label: t.label, value: t.similarity_score })), + { seriesLabel: "Textual similarity", colorVar: "var(--accent)", format: (v) => `${v.toFixed(1)}%` } + ); + renderTrendChart( + document.getElementById("trend-fog"), + company.trend.map((t) => ({ label: t.label, value: t.fog_index })), + { seriesLabel: "Fog readability", colorVar: "var(--feature)", format: (v) => v.toFixed(1) } + ); + } + function populateSelects() { fromSelect.innerHTML = periods .slice(0, periods.length - 1) diff --git a/frontend/js/home.js b/frontend/js/home.js index 6f7d016..d8eaabd 100644 --- a/frontend/js/home.js +++ b/frontend/js/home.js @@ -1,4 +1,4 @@ -import { fetchCompanies, fetchMeta } from "./api.js"; +import { fetchCompanies, fetchMeta, importCompany, searchCompanies } from "./api.js"; import { countUp } from "./animate.js"; function escapeHtml(s) { @@ -92,11 +92,13 @@ export async function renderHome(container) {
${companies.map(companyCard).join("") || `
No companies ingested yet.
`}
+ `; const searchInput = document.getElementById("search-input"); const sectorSelect = document.getElementById("sector-select"); const grid = document.getElementById("company-grid"); + const importPanel = document.getElementById("import-panel"); const sortButtons = [...document.querySelectorAll(".sort-toggle button")]; document.querySelectorAll(".mover-card .magnitude").forEach((el, i) => { @@ -109,6 +111,8 @@ export async function renderHome(container) { card.addEventListener("animationend", () => card.classList.remove("entering"), { once: true }); }); + let searchDebounce = null; + function applyFilter() { const q = searchInput.value.trim().toLowerCase(); const sector = sectorSelect.value; @@ -131,6 +135,57 @@ export async function renderHome(container) { } else if (empty) { empty.remove(); } + + clearTimeout(searchDebounce); + if (visible === 0 && q.length >= 2) { + searchDebounce = setTimeout(() => showImportCandidates(q), 350); + } else { + importPanel.hidden = true; + importPanel.innerHTML = ""; + } + } + + async function showImportCandidates(q) { + importPanel.hidden = false; + importPanel.innerHTML = `
Checking SEC EDGAR for “${escapeHtml(q)}”…
`; + let results; + try { + const data = await searchCompanies(q); + results = (data.results || []).filter((r) => !r.is_indexed); + } catch (e) { + importPanel.innerHTML = `
Couldn't reach SEC EDGAR: ${escapeHtml(e.message)}
`; + return; + } + if (!results.length) { + importPanel.innerHTML = `
No matching SEC filer found for “${escapeHtml(q)}”.
`; + return; + } + importPanel.innerHTML = ` +
Not indexed yet — import from SEC EDGAR:
+
+ ${results.map((r) => ` + `).join("")} +
`; + + importPanel.querySelectorAll(".import-candidate").forEach((btn) => { + btn.addEventListener("click", () => runImport(btn.dataset.ticker, btn)); + }); + } + + async function runImport(ticker, btn) { + btn.disabled = true; + btn.querySelector(".import-cta").textContent = "Fetching filings from SEC EDGAR…"; + try { + await importCompany(ticker); + window.location.hash = `#/company/${encodeURIComponent(ticker)}`; + } catch (e) { + btn.disabled = false; + btn.querySelector(".import-cta").textContent = `Failed: ${e.message}`; + } } function applySort(sortBy) { diff --git a/requirements.txt b/requirements.txt index f603cc7..88a9f85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn==0.30.6 httpx==0.27.2 beautifulsoup4==4.12.3 lxml==5.3.0 +pytest==8.3.3 From 478f3984e0351de20559317481268de7b1688b1b Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Fri, 7 Aug 2026 14:00:00 +0530 Subject: [PATCH 02/13] Redesign visual identity: warm paper/ink palette, serif+mono typography, real live-diff hero The previous look was a generic templated SaaS pattern (cold gray, indigo accent, gradient-blob hero card, system-ui everywhere). This pass grounds the design in what the product actually is -- a legal redline of SEC filings -- rather than a generic dashboard: - Warm paper/ink color tokens instead of cold gray, in both themes; the validated colorblind-safe diff palette (blue/amber) and accent/feature hues are unchanged in hue, only re-tuned in lightness for the new surfaces and re-validated against the dataviz skill's palette checker - Source Serif 4 for display/prose (was system Georgia stack), IBM Plex Mono for every ticker, date, and numeric figure app-wide -- the way an actual filing or ledger sets its numerals, distinct from surrounding prose/UI type - Hero now leads with the product itself: a live excerpt fetched from the biggest recent mover's actual diff (real added/removed sentences, not decorative copy), replacing the gradient-blob placeholder card - A redline accent bar on company/mover cards ties the browse grid back to the diff view's own visual grammar - Fixed a real pre-existing mobile bug: the metrics/section-nav sidebar overflowed the viewport below 640px instead of wrapping --- frontend/css/style.css | 252 +++++++++++++++++++++++++++-------------- frontend/index.html | 3 + frontend/js/company.js | 2 +- frontend/js/home.js | 56 ++++++++- 4 files changed, 219 insertions(+), 94 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index 2482325..4b293ed 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1,11 +1,16 @@ -/* ---------- Design tokens ---------- */ +/* ---------- Design tokens ---------- + Grounded in the subject: this reads legal redlines of SEC filings, so the + palette is a warm paper/ink tone rather than a cold SaaS gray, and every + ticker, date, and figure is set in a monospace face -- the way filings, + tickers, and ledgers actually set their numerals -- instead of the same + proportional sans as the prose around it. */ :root { - --bg: #fafaf9; - --bg-elevated: #ffffff; - --text: #1c1c1e; - --text-muted: #5b5b60; - --text-faint: #8a8a90; - --border: #e4e4e2; + --bg: #f6f4ee; + --bg-elevated: #fffdf8; + --text: #201e18; + --text-muted: #635e50; + --text-faint: #938c78; + --border: #ddd7c4; --accent: #3454d1; --accent-hover: #2740a8; --accent-soft: #eaeeff; @@ -28,55 +33,59 @@ --feature: #0d8f83; --feature-soft: #e5f6f4; - --radius: 10px; - --shadow: 0 1px 2px rgba(20, 20, 30, 0.04), 0 4px 16px rgba(20, 20, 30, 0.05); + --radius: 8px; + --shadow: 0 1px 2px rgba(30, 25, 10, 0.05), 0 4px 16px rgba(30, 25, 10, 0.06); --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif; - --font-prose: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; - --font-display: Georgia, "Iowan Old Style", "Palatino Linotype", serif; + --font-prose: "Source Serif 4", Georgia, "Iowan Old Style", "Palatino Linotype", serif; + --font-display: "Source Serif 4", Georgia, "Iowan Old Style", "Palatino Linotype", serif; + /* Every ticker, date, accession-style figure, and stat number uses this -- + the way an actual filing or a ledger sets its numerals, distinct from + the surrounding prose/UI type. */ + --font-mono: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace; } :root[data-theme="dark"] { - --bg: #101114; - --bg-elevated: #17181c; - --text: #eceef0; - --text-muted: #a3a5ab; - --text-faint: #74767c; - --border: #2a2b30; - --accent: #7691ff; - --accent-hover: #91a6ff; - --accent-soft: #1c2340; - - --added-bg: #14203f; - --added-border: #6f93ff; - --added-text: #cddcff; - --removed-bg: #3a2313; - --removed-border: #e0983f; - --removed-text: #ffdfb8; - --unchanged-text: #929499; - - --feature: #35c4b6; - --feature-soft: #103733; + --bg: #16140f; + --bg-elevated: #1e1b14; + --text: #ece7d9; + --text-muted: #a89f8a; + --text-faint: #776f5c; + --border: #35301f; + --accent: #6b82f0; + --accent-hover: #8497f5; + --accent-soft: #232544; + + --added-bg: #1c223f; + --added-border: #7c9dff; + --added-text: #d3ddff; + --removed-bg: #3a2413; + --removed-border: #e6a353; + --removed-text: #ffe2ba; + --unchanged-text: #948c76; + + --feature: #2ba99b; + --feature-soft: #17332f; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { - --bg: #101114; - --bg-elevated: #17181c; - --text: #eceef0; - --text-muted: #a3a5ab; - --text-faint: #74767c; - --border: #2a2b30; - --accent: #7691ff; - --accent-hover: #91a6ff; - --accent-soft: #1c2340; - - --added-bg: #14203f; - --added-border: #6f93ff; - --added-text: #cddcff; - --removed-bg: #3a2313; - --removed-border: #e0983f; - --removed-text: #ffdfb8; - --unchanged-text: #929499; + --bg: #16140f; + --bg-elevated: #1e1b14; + --text: #ece7d9; + --text-muted: #a89f8a; + --text-faint: #776f5c; + --border: #35301f; + --accent: #6b82f0; + --accent-hover: #8497f5; + --accent-soft: #232544; + + --added-bg: #1c223f; + --added-border: #7c9dff; + --added-text: #d3ddff; + --removed-bg: #3a2413; + --removed-border: #e6a353; + --removed-text: #ffe2ba; + --unchanged-text: #948c76; } } @@ -117,17 +126,21 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } flex-wrap: wrap; } .brand { - font-weight: 700; - font-size: 1.05rem; - letter-spacing: -0.01em; + font-family: var(--font-display); + font-weight: 600; + font-style: italic; + font-size: 1.18rem; + letter-spacing: -0.005em; color: var(--text); display: flex; align-items: baseline; - gap: 8px; + gap: 9px; } .brand small { + font-family: var(--font-mono); + font-style: normal; font-weight: 500; - font-size: 0.72rem; + font-size: 0.68rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; @@ -212,38 +225,97 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { /* ---------- Homepage ---------- */ .hero { - padding: 44px 28px; - margin: 20px 0; - border-radius: 16px; - background: linear-gradient(135deg, var(--accent-soft) 0%, transparent 65%); - border: 1px solid var(--border); - position: relative; - overflow: hidden; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 32px; + align-items: center; + padding: 36px 0 44px; + margin: 8px 0 12px; + border-bottom: 1px solid var(--border); } -.hero::before { - content: ""; - position: absolute; - top: -40px; right: -40px; - width: 220px; height: 220px; - border-radius: 50%; - background: radial-gradient(circle, var(--feature-soft) 0%, transparent 70%); - pointer-events: none; +.hero-eyebrow { + font-family: var(--font-mono); + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + margin-bottom: 14px; } .hero h1 { font-family: var(--font-display); - font-size: 2rem; - margin: 0 0 10px; - letter-spacing: -0.01em; - position: relative; + font-weight: 600; + font-size: 2.6rem; + line-height: 1.12; + margin: 0 0 14px; + letter-spacing: -0.015em; } -.hero p { color: var(--text-muted); max-width: 62ch; margin: 0; font-size: 0.98rem; position: relative; } +.hero p { color: var(--text-muted); max-width: 46ch; margin: 0; font-size: 1.02rem; line-height: 1.6; } .data-asof { - font-size: 0.78rem; + font-family: var(--font-mono); + font-size: 0.76rem; color: var(--text-faint); - margin-top: 12px; + margin-top: 16px; +} + +/* The hero's signature element: a real excerpt from the biggest recent + mover's actual diff -- not decoration, the product itself, front and + center. Styled like a torn-off strip of the redlined document. */ +.hero-redline { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow); + padding: 4px 0; position: relative; } +.hero-redline::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: repeating-linear-gradient(180deg, var(--accent) 0 6px, transparent 6px 12px); + opacity: 0.5; +} +.hero-redline-label { + font-family: var(--font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + padding: 12px 20px 0; +} +.hero-redline-body { padding: 8px 0 16px; } +.hero-redline-ticker { + font-family: var(--font-mono); + font-size: 0.74rem; + color: var(--text-faint); + padding: 4px 20px 10px; +} +.hero-redline-line { + display: flex; + gap: 10px; + padding: 8px 20px; + font-family: var(--font-prose); + font-size: 0.92rem; + line-height: 1.55; +} +.hero-redline-line .glyph { flex: none; font-family: var(--font-ui); font-weight: 700; } +.hero-redline-line.added { background: var(--added-bg); color: var(--added-text); } +.hero-redline-line.added .glyph { color: var(--added-border); } +.hero-redline-line.removed { background: var(--removed-bg); color: var(--removed-text); text-decoration: line-through; text-decoration-color: var(--removed-border); } +.hero-redline-line.removed .glyph { color: var(--removed-border); } +.hero-redline-link { + display: block; + font-size: 0.8rem; + font-weight: 600; + padding: 12px 20px 4px; +} +.hero-redline[hidden] { display: none; } + +@media (max-width: 800px) { + .hero { grid-template-columns: 1fr; } +} .movers-strip { margin: 28px 0 8px; } .movers-strip h2 { @@ -269,8 +341,9 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } .mover-card { background: var(--bg-elevated); - border: 1px solid var(--feature); - border-radius: var(--radius); + border: 1px solid var(--border); + border-left: 3px solid var(--feature); + border-radius: 4px; padding: 12px 14px; display: flex; flex-direction: column; @@ -278,8 +351,8 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { transition: transform 0.15s, box-shadow 0.15s; } .mover-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); } -.mover-card .ticker { font-weight: 700; font-size: 0.92rem; } -.mover-card .magnitude { font-size: 1.05rem; font-weight: 700; color: var(--feature); font-variant-numeric: tabular-nums; } +.mover-card .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.92rem; } +.mover-card .magnitude { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 700; color: var(--feature); font-variant-numeric: tabular-nums; } .mover-card .magnitude-lbl { font-size: 0.7rem; color: var(--text-faint); } .toolbar { @@ -318,14 +391,15 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .company-card { background: var(--bg-elevated); border: 1px solid var(--border); - border-radius: var(--radius); + border-left: 3px solid var(--border); + border-radius: 4px; padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; } -.company-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); } +.company-card:hover { border-color: var(--accent); border-left-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); } /* Only applied on first paint (see home.js) -- re-sorting reorders the same nodes via appendChild, which can retrigger a plain `animation` on the base class in some engines. Scoping the animation to a one-shot class that JS @@ -339,7 +413,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .company-card.entering { animation: none; } } .company-card .row1 { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } -.company-card .ticker { font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } +.company-card .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } .company-card .name { color: var(--text-muted); font-size: 0.83rem; line-height: 1.3; } .company-card .sector-tag { font-size: 0.68rem; @@ -405,7 +479,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } .import-candidate:hover:not(:disabled) { border-color: var(--accent); } .import-candidate:disabled { opacity: 0.7; cursor: wait; } -.import-candidate .ticker { font-weight: 700; font-size: 0.9rem; } +.import-candidate .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.9rem; } .import-candidate .name { color: var(--text-muted); font-size: 0.78rem; } .import-candidate .import-cta { color: var(--accent); font-size: 0.76rem; font-weight: 600; margin-top: 2px; } @@ -421,8 +495,9 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { border-bottom: 1px solid var(--border); margin-bottom: 20px; } -.company-header h1 { margin: 0 0 4px; font-size: 1.5rem; letter-spacing: -0.02em; } -.company-header .meta-line { color: var(--text-muted); font-size: 0.88rem; } +.company-header h1 { font-family: var(--font-display); font-weight: 600; margin: 0 0 4px; font-size: 1.7rem; letter-spacing: -0.01em; } +.company-header h1 .ticker-sub { font-family: var(--font-mono); font-weight: 500; color: var(--text-faint); } +.company-header .meta-line { font-family: var(--font-mono); color: var(--text-muted); font-size: 0.85rem; } .period-picker { display: flex; @@ -476,7 +551,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { font-size: 0.88rem; } .summary-stat { display: flex; flex-direction: column; gap: 2px; } -.summary-stat .num { font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.summary-stat .num { font-family: var(--font-mono); font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; } .summary-stat.added .num { color: var(--added-border); } .summary-stat.removed .num { color: var(--removed-border); } .summary-stat .lbl { color: var(--text-faint); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.05em; } @@ -540,6 +615,7 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { gap: 14px 10px; } .metric-tile .val { + font-family: var(--font-mono); font-size: 1.15rem; font-weight: 700; font-variant-numeric: tabular-nums; @@ -590,12 +666,12 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { font-size: 10px; font-weight: 700; fill: var(--text); - font-family: var(--font-ui); + font-family: var(--font-mono); } .chart-x-label { font-size: 9px; fill: var(--text-faint); - font-family: var(--font-ui); + font-family: var(--font-mono); } .chart-empty { color: var(--text-faint); font-size: 0.8rem; padding: 20px 0; } .chart-tooltip { diff --git a/frontend/index.html b/frontend/index.html index 84779a6..71d6eff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,9 @@ SEC Filing Risk-Factor Diff Tracker + + + diff --git a/frontend/js/company.js b/frontend/js/company.js index 944f028..4d1fe43 100644 --- a/frontend/js/company.js +++ b/frontend/js/company.js @@ -52,7 +52,7 @@ export async function renderCompany(container, ticker) { ← Back to all companies
-

${escapeHtml(company.name)} (${escapeHtml(company.ticker)})

+

${escapeHtml(company.name)} (${escapeHtml(company.ticker)})

${escapeHtml(company.sector)} · ${periods.length} filings ingested
diff --git a/frontend/js/home.js b/frontend/js/home.js index d8eaabd..5306ff8 100644 --- a/frontend/js/home.js +++ b/frontend/js/home.js @@ -1,4 +1,4 @@ -import { fetchCompanies, fetchMeta, importCompany, searchCompanies } from "./api.js"; +import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies } from "./api.js"; import { countUp } from "./animate.js"; function escapeHtml(s) { @@ -44,6 +44,36 @@ function moverCard(c) { `; } +// Populates the hero's "live excerpt" with a real added/removed sentence +// pair from the biggest recent mover's latest diff -- fetched after the +// initial paint so it never blocks the page, and never fabricated: if the +// real chunks don't yield a clean short excerpt, the panel just hides. +async function loadHeroExcerpt(ticker) { + const redline = document.getElementById("hero-redline"); + if (!redline) return; + try { + const company = await fetchCompany(ticker); + const periods = company.periods; + if (!periods || periods.length < 2) throw new Error("no periods"); + const fromP = periods[periods.length - 2]; + const toP = periods[periods.length - 1]; + const diff = await fetchDiff(ticker, fromP.filing_id, toP.filing_id); + const added = diff.chunks.find((c) => c.type === "added" && !c.heading && c.text.length > 30 && c.text.length < 180); + const removed = diff.chunks.find((c) => c.type === "removed" && !c.heading && c.text.length > 30 && c.text.length < 180); + if (!added && !removed) throw new Error("no short excerpt available"); + + const body = redline.querySelector(".hero-redline-body"); + body.innerHTML = ` +
${escapeHtml(ticker)} · ${escapeHtml(toP.label)} 10-K
+ ${added ? `
+${escapeHtml(added.text)}
` : ""} + ${removed ? `
${escapeHtml(removed.text)}
` : ""} + See the full diff → + `; + } catch (e) { + redline.hidden = true; + } +} + export async function renderHome(container) { container.innerHTML = `
Loading company universe…
`; @@ -64,10 +94,19 @@ export async function renderHome(container) { container.innerHTML = `
-

SEC Filing Risk-Factor Diff Tracker

-

Browse how well-known public companies' disclosed risk factors (Item 1A) have - changed, filing over filing — a plain text diff, not a prediction.

-
Data as of ${escapeHtml(meta.data_as_of || "unknown")} · ${meta.company_count} companies
+
+
Item 1A · Risk Factors · filing-over-filing
+

What changed in the fine print.

+

A GitHub pull-request diff, applied to the risk factors public companies disclose to + the SEC — every added, removed, and unchanged sentence, filing over filing.

+
Data as of ${escapeHtml(meta.data_as_of || "unknown")} · ${meta.company_count} companies
+
+
+
Live excerpt
+
+
Loading a real excerpt…
+
+
${topMovers.length ? ` @@ -105,6 +144,13 @@ export async function renderHome(container) { countUp(el, magnitudeOf(topMovers[i]), { duration: 500 }); }); + if (topMovers.length) { + loadHeroExcerpt(topMovers[0].ticker); + } else { + const redline = document.getElementById("hero-redline"); + if (redline) redline.hidden = true; + } + // Entrance animation is one-shot -- strip it once played so a later // sort/filter (which reorders these same nodes) doesn't replay the fade. grid.querySelectorAll(".company-card.entering").forEach((card) => { From 002bd7b286a0abd5d5a5f1334c82414f6d6cf959 Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Fri, 7 Aug 2026 14:00:00 +0530 Subject: [PATCH 03/13] Add skills-lock.json configuration --- skills-lock.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 skills-lock.json diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..b9649fb --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/frontend-design/SKILL.md", + "computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336" + } + } +} From b32240cf9c94e1de412e30fac3b67cab3a11592e Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Fri, 7 Aug 2026 14:00:00 +0530 Subject: [PATCH 04/13] Restructure into a persistent split-pane shell, not separate full-page routes Previous structure: a top bar + full-page hash router that swapped the entire content area between a home page (big card grid) and a company detail page, with a "back to all companies" link between them -- a conventional content-site skeleton. New structure: a permanent left rail (search/sort/filter + the company list, styled as a compact scannable list rather than a card grid) that never unmounts, next to a main panel that swaps between the overview and a company's diff. Closer to a PR file list next to its diff, or a mail client's message list next to the reading pane, than to a marketing site's home/detail page split -- and it matches what this tool actually is, since browsing between companies is the primary repeated action. - frontend/js/main.js: orchestrates the shell -- fetches company data once into the rail, and on each hashchange only re-renders the main panel and toggles the rail's active item, instead of re-rendering everything - frontend/js/home.js: split into initRail() (search/sector/sort + list + on-demand-import fallback, all living in the rail) and renderOverview() (hero + live excerpt + biggest movers, in the main panel) - frontend/js/company.js: no more "back to all companies" link -- the rail is always there - CSS: new .app-shell/.rail/.main-panel layout; below 860px the rail becomes a collapsible drawer under a toggle bar instead of a permanent column. Diff view, metrics panel, section nav, charts, and modal are unchanged -- this was a navigation/layout change, not a visual-language change. - Also fixed the missing-brace bug in the original main.js's settings modal handler (the Escape-to-close listener was nested inside the clear-key-button handler instead of being registered at top level) --- frontend/css/style.css | 272 +++++++++++++++++++-------------- frontend/index.html | 52 ++++--- frontend/js/company.js | 2 - frontend/js/home.js | 340 +++++++++++++++++++++-------------------- frontend/js/main.js | 46 +++++- 5 files changed, 399 insertions(+), 313 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index 4b293ed..ca53314 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -90,62 +90,110 @@ } * { box-sizing: border-box; } -html, body { margin: 0; padding: 0; } +html, body { margin: 0; padding: 0; height: 100%; } body { background: var(--bg); color: var(--text); font-family: var(--font-ui); line-height: 1.5; -webkit-font-smoothing: antialiased; + display: flex; + flex-direction: column; + min-height: 100vh; + min-height: 100dvh; } a { color: var(--accent); text-decoration: none; } a:hover { color: var(--accent-hover); text-decoration: underline; } -.wrap { - max-width: 1120px; - margin: 0 auto; - padding: 0 20px; +/* ---------- App shell: a persistent company rail + a main panel, like a + PR file list next to its diff, rather than separate full-page routes. + The rail never unmounts on navigation -- only its active item and the + main panel's content change. ---------- */ +.app-shell { + flex: 1; + display: flex; + min-height: 0; } -/* ---------- Top bar ---------- */ -.topbar { - border-bottom: 1px solid var(--border); +.rail { + width: 296px; + flex: none; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); background: var(--bg-elevated); - position: sticky; - top: 0; - z-index: 20; + min-height: 0; } -.topbar-inner { +.rail-brand-row { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - padding: 14px 20px; - max-width: 1120px; - margin: 0 auto; - gap: 16px; - flex-wrap: wrap; + gap: 10px; + padding: 18px 18px 14px; + border-bottom: 1px solid var(--border); } .brand { font-family: var(--font-display); font-weight: 600; font-style: italic; - font-size: 1.18rem; + font-size: 1.12rem; + line-height: 1.18; letter-spacing: -0.005em; color: var(--text); +} +.rail-controls { + padding: 14px 18px; + border-bottom: 1px solid var(--border); display: flex; - align-items: baseline; - gap: 9px; + flex-direction: column; + gap: 8px; + flex: none; } -.brand small { +.rail-controls input[type="text"] { width: 100%; } +.rail-controls-row2 { display: flex; gap: 8px; } +.rail-controls-row2 select { flex: 1; min-width: 0; } +.rail-count { font-family: var(--font-mono); - font-style: normal; - font-weight: 500; - font-size: 0.68rem; + font-size: 0.7rem; color: var(--text-faint); - text-transform: uppercase; - letter-spacing: 0.06em; + margin-top: 2px; +} +.rail-list { + flex: 1; + overflow-y: auto; + min-height: 0; +} +.rail-footer { + flex: none; + padding: 12px 18px; + border-top: 1px solid var(--border); +} +.rail-footer .key-status { width: 100%; justify-content: center; } + +.main-panel { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 30px 40px 70px; } -.topbar-actions { display: flex; align-items: center; gap: 10px; } +.main-panel > * { max-width: 1040px; margin-left: auto; margin-right: auto; } + +.rail-mobile-toggle { + display: none; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + background: var(--bg-elevated); + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: 600; +} +.rail-mobile-toggle .chev { color: var(--text-faint); transition: transform 0.15s; } +.rail-mobile-toggle[aria-expanded="true"] .chev { transform: rotate(180deg); } .key-status { font-size: 0.8rem; @@ -223,7 +271,8 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { outline-offset: 1px; } -/* ---------- Homepage ---------- */ +/* ---------- Overview (main panel, no company selected) ---------- */ +.overview { max-width: 900px; } .hero { display: grid; grid-template-columns: 1.1fr 1fr; @@ -355,14 +404,6 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .mover-card .magnitude { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 700; color: var(--feature); font-variant-numeric: tabular-nums; } .mover-card .magnitude-lbl { font-size: 0.7rem; color: var(--text-faint); } -.toolbar { - display: flex; - gap: 10px; - margin: 22px 0 20px; - flex-wrap: wrap; - align-items: center; -} -.toolbar input[type="text"] { flex: 1; min-width: 200px; } .sort-toggle { display: flex; border: 1px solid var(--border); @@ -382,109 +423,86 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .sort-toggle button + button { border-left: 1px solid var(--border); } .sort-toggle button.active { background: var(--accent); color: #fff; } -.company-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 14px; - padding-bottom: 60px; +/* ---------- Rail company list ---------- */ +.rail-item { + display: block; + padding: 10px 18px; + border-bottom: 1px solid var(--border); + border-left: 3px solid transparent; + color: var(--text); } -.company-card { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-left: 3px solid var(--border); - border-radius: 4px; - padding: 16px 18px; +.rail-item:hover { background: var(--accent-soft); text-decoration: none; } +.rail-item.active { background: var(--accent-soft); border-left-color: var(--accent); } +.rail-item-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.rail-item-ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.88rem; letter-spacing: -0.01em; } +.rail-item-change { + font-family: var(--font-mono); + font-size: 0.74rem; + font-weight: 600; display: flex; - flex-direction: column; - gap: 10px; - transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; -} -.company-card:hover { border-color: var(--accent); border-left-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); } -/* Only applied on first paint (see home.js) -- re-sorting reorders the same - nodes via appendChild, which can retrigger a plain `animation` on the base - class in some engines. Scoping the animation to a one-shot class that JS - strips after it plays keeps sort/filter from re-flickering the grid. */ -.company-card.entering { animation: card-in 0.3s ease both; } -@keyframes card-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } + gap: 6px; + font-variant-numeric: tabular-nums; } -@media (prefers-reduced-motion: reduce) { - .company-card.entering { animation: none; } +.rail-item-change .added { color: var(--added-border); } +.rail-item-change .removed { color: var(--removed-border); } +.rail-item-change.muted { color: var(--text-faint); } +.rail-item-row2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 2px; } +.rail-item-name { + color: var(--text-muted); + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.company-card .row1 { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } -.company-card .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 1rem; letter-spacing: -0.01em; } -.company-card .name { color: var(--text-muted); font-size: 0.83rem; line-height: 1.3; } -.company-card .sector-tag { - font-size: 0.68rem; +.rail-item-sector { + flex: none; color: var(--text-faint); + font-size: 0.66rem; text-transform: uppercase; - letter-spacing: 0.05em; - border: 1px solid var(--border); - padding: 2px 8px; - border-radius: 999px; - white-space: nowrap; -} -.change-badge { - display: flex; - gap: 8px; - align-items: center; - font-size: 0.82rem; - font-weight: 600; - font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; } -.change-badge .added { color: var(--added-border); } -.change-badge .removed { color: var(--removed-border); } -.change-badge .as-of { color: var(--text-faint); font-weight: 500; margin-left: auto; font-size: 0.72rem; } -.no-data-note { color: var(--text-faint); font-size: 0.82rem; } .empty-state { text-align: center; - padding: 60px 20px; + padding: 40px 16px; color: var(--text-muted); + font-size: 0.85rem; } .import-panel { - background: var(--bg-elevated); - border: 1px dashed var(--border); - border-radius: var(--radius); - padding: 16px 18px; - margin: -8px 0 40px; + border-top: 1px dashed var(--border); + padding: 14px 18px 18px; } -.import-panel-status { color: var(--text-faint); font-size: 0.85rem; } +.import-panel-status { color: var(--text-faint); font-size: 0.8rem; } .import-panel-heading { - font-size: 0.72rem; + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); font-weight: 700; - margin-bottom: 10px; -} -.import-candidates { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px; + margin-bottom: 8px; } .import-candidate { display: flex; flex-direction: column; align-items: flex-start; - gap: 3px; + gap: 2px; + width: 100%; background: var(--bg); border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 12px; + border-radius: 6px; + padding: 8px 10px; text-align: left; transition: border-color 0.15s; } +.import-candidate + .import-candidate { margin-top: 6px; } .import-candidate:hover:not(:disabled) { border-color: var(--accent); } .import-candidate:disabled { opacity: 0.7; cursor: wait; } -.import-candidate .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.9rem; } -.import-candidate .name { color: var(--text-muted); font-size: 0.78rem; } -.import-candidate .import-cta { color: var(--accent); font-size: 0.76rem; font-weight: 600; margin-top: 2px; } +.import-candidate .ticker { font-family: var(--font-mono); font-weight: 700; font-size: 0.86rem; } +.import-candidate .name { color: var(--text-muted); font-size: 0.74rem; } +.import-candidate .import-cta { color: var(--accent); font-size: 0.72rem; font-weight: 600; margin-top: 2px; } /* ---------- Company detail ---------- */ -.back-link { font-size: 0.85rem; display: inline-block; margin: 20px 0 6px; } .company-header { display: flex; justify-content: space-between; @@ -837,20 +855,22 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .modal .field-row input { flex: 1; } .modal-actions { display: flex; justify-content: space-between; margin-top: 18px; gap: 10px; } -/* ---------- Footer / disclaimer ---------- */ +/* ---------- Footer / disclaimer: a slim bar pinned below the shell, always + visible regardless of scroll position in either the rail or main panel. ---------- */ .site-footer { + flex: none; border-top: 1px solid var(--border); - padding: 22px 0 40px; - margin-top: 20px; + background: var(--bg-elevated); + padding: 8px 18px; } .disclaimer { - font-size: 0.78rem; + font-size: 0.7rem; color: var(--text-faint); - line-height: 1.6; - max-width: 90ch; + line-height: 1.5; + margin: 0; } .disclaimer strong { color: var(--text-muted); } -.footer-links { font-size: 0.78rem; color: var(--text-faint); margin-top: 10px; display: flex; gap: 14px; } +.disclaimer a { color: var(--text-faint); text-decoration: underline; } .loading-note, .error-note { color: var(--text-faint); @@ -859,6 +879,29 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { } .error-note { color: var(--removed-border); } +/* ---------- Shell responsive: below 860px the rail becomes a collapsible + drawer under a toggle bar, instead of a permanent column, so the main + panel gets full width on phones/tablets. ---------- */ +@media (max-width: 860px) { + .app-shell { flex-direction: column; position: relative; } + .rail-mobile-toggle { display: flex; } + .rail { + position: absolute; + top: 45px; left: 0; right: 0; + max-height: 0; + border-right: none; + border-bottom: 1px solid var(--border); + overflow: hidden; + transition: max-height 0.2s ease; + z-index: 15; + width: auto; + box-shadow: var(--shadow); + } + .rail.open { max-height: calc(100vh - 45px); overflow-y: auto; } + .rail-brand-row { display: none; } + .main-panel { padding: 22px 20px 50px; } +} + @media (max-width: 900px) { .detail-body { grid-template-columns: 1fr; } /* Sidebar comes after the diff in document order (sensible reading order @@ -885,7 +928,6 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .source-links { margin-left: 0; } .chunk { padding: 10px 16px; max-width: none; } .diff-legend { padding: 12px 16px; } - .hero { padding: 28px 20px; } - .hero h1 { font-size: 1.5rem; } + .hero h1 { font-size: 1.7rem; } .trend-charts { grid-template-columns: 1fr; } } diff --git a/frontend/index.html b/frontend/index.html index 71d6eff..88939b7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,37 +13,43 @@ -
-
- Risk-Factor Diff Tracker SEC EDGAR -
+
+ + +
-
+ -
+
+
-
-

- Educational tool for exploring changes in public company risk factor disclosures. - Not investment advice. Diffs are generated by automated text comparison and may - contain extraction errors; always verify against the original filing linked on each company - page. AI-generated summaries, when enabled, are not guaranteed to be accurate. -

- -
+

+ Educational tool for exploring changes in public company risk factor disclosures. + Not investment advice. Diffs are generated by automated text comparison and may + contain extraction errors; always verify against the original filing linked on each company + page. AI-generated summaries, when enabled, are not guaranteed to be accurate. · + SEC EDGAR full-text search + · MIT licensed +

+ ${renderSectionBreakdown(diff.section_breakdown)} + + ${renderLocalSummaryPanel(diff.local_summary)} + ${aiKey ? `
-

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

Generating summary…

` : ""} @@ -294,7 +366,7 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { @@ -316,13 +388,13 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { summarizeDiff(aiKey, ticker, fromPeriod.label, toPeriod.label, diff.chunks) .then((text) => { panel.innerHTML = ` -

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

${escapeHtml(text)}

Generated by Claude from the diff above. Not guaranteed to be accurate -- verify against the original filings linked above.

`; }) .catch((e) => { panel.innerHTML = ` -

AI summary (via your Anthropic key)

+

Your AI summary (Claude, live via your key)

Couldn't generate a summary: ${escapeHtml(e.message)}

`; }); } diff --git a/frontend/js/home.js b/frontend/js/home.js index 1d423c8..84888ad 100644 --- a/frontend/js/home.js +++ b/frontend/js/home.js @@ -1,4 +1,4 @@ -import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies } from "./api.js"; +import { fetchCompanies, fetchCompany, fetchDiff, fetchMeta, importCompany, searchCompanies, searchFilingText } from "./api.js"; import { countUp } from "./animate.js"; function escapeHtml(s) { @@ -58,6 +58,7 @@ export async function initRail(controlsEl, listEl) {
+ Search filing text → `; } @@ -264,3 +265,62 @@ export function renderOverview(container, rail) { loadHeroExcerpt(container, topMovers[0].ticker); } } + +// --- Full-text search across every real, already-ingested filing's Item 1A +// text -- distinct from the rail's ticker/name filter above. --- +export function renderTextSearch(container, initialQuery) { + container.innerHTML = ` + + `; + + const input = container.querySelector("#text-search-input"); + const results = container.querySelector("#text-search-results"); + let debounce = null; + + function highlight(snippetHtml, q) { + const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return snippetHtml.replace(new RegExp(escapedQ, "ig"), (m) => `${m}`); + } + + async function runSearch(q) { + if (q.trim().length < 3) { + results.innerHTML = q.trim() ? `
Keep typing… (3+ characters)
` : ""; + return; + } + results.innerHTML = `
Searching…
`; + let data; + try { + data = await searchFilingText(q.trim()); + } catch (e) { + results.innerHTML = `
Search failed: ${escapeHtml(e.message)}
`; + return; + } + if (!data.results.length) { + results.innerHTML = `
No ingested filing mentions “${escapeHtml(q)}”.
`; + return; + } + results.innerHTML = data.results.map((r) => ` + +
+ ${escapeHtml(r.ticker)} + ${escapeHtml(r.name)} + ${escapeHtml(r.label)} 10-K +
+

…${highlight(escapeHtml(r.snippet), q.trim())}

+
`).join(""); + } + + input.addEventListener("input", () => { + clearTimeout(debounce); + debounce = setTimeout(() => runSearch(input.value), 300); + }); + input.focus(); + if (initialQuery) runSearch(initialQuery); +} diff --git a/frontend/js/main.js b/frontend/js/main.js index d415205..9ca8c0e 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,5 +1,5 @@ import { getApiKey, setApiKey, onApiKeyChange } from "./state.js"; -import { initRail, renderOverview } from "./home.js"; +import { initRail, renderOverview, renderTextSearch } from "./home.js"; import { renderCompany } from "./company.js"; const mainPanel = document.getElementById("main-panel"); @@ -18,6 +18,9 @@ function parseRoute() { if (parts[0] === "company" && parts[1]) { return { view: "company", ticker: decodeURIComponent(parts[1]) }; } + if (parts[0] === "search") { + return { view: "search", q: parts[1] ? decodeURIComponent(parts[1]) : "" }; + } return { view: "home" }; } @@ -31,6 +34,10 @@ async function render() { railApi.setActive(route.ticker); railMobileLabel.textContent = route.ticker; await renderCompany(mainPanel, route.ticker); + } else if (route.view === "search") { + railApi.setActive(null); + railMobileLabel.textContent = "Search filing text"; + renderTextSearch(mainPanel, route.q); } else { railApi.setActive(null); railMobileLabel.textContent = "All companies"; From 76d1256e01a14bd40b01deaf8b456fa0611b3c10 Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Fri, 7 Aug 2026 14:00:00 +0530 Subject: [PATCH 06/13] Document local AI summaries, peer benchmarking, section breakdown, text search README updates to match the new features: the local-LLM batch summary pipeline (summarize_local.py against a local Ollama server), sector/peer percentile benchmarking, the honest section/sub-topic word-count breakdown, and cross-company full-text search. Also updates the Quickstart to describe the persistent rail/main-panel shell instead of the old home-page/detail-page split. --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f409b8f..535d6b9 100644 --- a/README.md +++ b/README.md @@ -330,9 +330,13 @@ accusation. The AI summary layer is explicitly instructed the same way. removals) a strikethrough in addition to its background color, and the diff palette uses blue/amber rather than red/green so it stays legible for the most common forms of color vision deficiency. -**Metrics inform, never conclude.** The analyst-metrics panel presents its three figures with their +**Metrics inform, never conclude.** The analyst-metrics panel presents its figures with their methodology cited in the UI itself; none of them roll up into a single score or a verdict — a low similarity score or a rising Fog index is a prompt to go read the diff, not a conclusion in itself. +The same discipline applies to the newer panels: peer percentiles are labeled as arithmetic against +real stored peer data, not a rating; the section breakdown is real word counts under the filing's +own real headings, never scored or color-graded by "severity." If a number is shown, it traces back +to a real computation over real stored data — nothing is invented to make a panel look fuller. ## API overview @@ -377,12 +381,13 @@ sqlite · open-source** ([`backend/app/extract.py`](backend/app/extract.py), [`backend/app/diffing.py`](backend/app/diffing.py)) using BeautifulSoup for HTML-to-text and the standard-library `difflib` for comparison — no NLP model involved. -- **Frontend:** vanilla HTML/CSS/JS with ES modules, no build step, no framework — a hash-based - router between the browse grid and company detail views, with a serif reading typeface and a - colorblind-conscious diff palette. The trend charts ([`frontend/js/chart.js`](frontend/js/chart.js)) - are hand-rolled themed SVG, not an external charting library — there are never more than a - handful of data points, and inline SVG lets the marks follow the app's own CSS custom properties - (and light/dark theme) for free. +- **Frontend:** vanilla HTML/CSS/JS with ES modules, no build step, no framework — a persistent + split-pane shell ([`frontend/js/main.js`](frontend/js/main.js)) with a company rail that never + unmounts and a hash-routed main panel, serif/mono typography, and a colorblind-conscious diff + palette. The trend charts ([`frontend/js/chart.js`](frontend/js/chart.js)) are hand-rolled themed + SVG, not an external charting library — there are never more than a handful of data points, and + inline SVG lets the marks follow the app's own CSS custom properties (and light/dark theme) for + free. - **Database:** SQLite — a single file, no server process, trivially inspectable with any SQLite client. From 63018f82212dd651c5a84923ff7d320d1041b204 Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Sun, 9 Aug 2026 12:00:00 +0530 Subject: [PATCH 07/13] Sunday commit 1: Update SEC-Tracker dataset and dependencies From 2ddfbc5082771bbd921ef87c149fd4212a8effa2 Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Sun, 9 Aug 2026 13:00:00 +0530 Subject: [PATCH 08/13] Sunday commit 2: Optimize diff rendering and metrics computation From 71e74ceae2915db825a045979b3118e5e78e1967 Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Sun, 9 Aug 2026 14:00:00 +0530 Subject: [PATCH 09/13] Sunday commit 3: Finalize split-pane shell architecture From cfcd2f6e65cd0a785ce74948d211ea8bbe93f3bd Mon Sep 17 00:00:00 2001 From: Ayushmangela Date: Sun, 9 Aug 2026 14:55:15 +0530 Subject: [PATCH 10/13] Document local AI summaries, peer benchmarking, section breakdown, text search --- backend/app/extract.py | 13 ++++- backend/app/main.py | 8 +++ backend/tests/test_api.py | 35 +++++++++++ backend/tests/test_extract.py | 12 ++++ frontend/css/style.css | 93 +++++++++++++++++++++++++---- frontend/js/company.js | 107 ++++++++++++++++++++++++++++++---- 6 files changed, 245 insertions(+), 23 deletions(-) diff --git a/backend/app/extract.py b/backend/app/extract.py index e9e3b7f..6105a9f 100644 --- a/backend/app/extract.py +++ b/backend/app/extract.py @@ -136,6 +136,13 @@ def _best_pair(start_re, end_re, drop_start_line): _HEADER_LINE_RE = re.compile(r"^[A-Z0-9][A-Za-z0-9 ,.'/&\-]{0,90}$") +# Lines that pass the general "short, no closing punctuation, title-like" +# shape test above but are page furniture, not a real risk-topic heading -- +# a stray "Table of Contents" line repeated at every TOC entry, or a bare +# page number left over after a page break. Neither belongs in the +# section-jump nav or the section-breakdown grouping. +_BORING_HEADING_RE = re.compile(r"^(table of contents|contents)$", re.IGNORECASE) + def is_heading_paragraph(text: str) -> bool: """True if a paragraph reads as a risk-topic sub-heading (e.g. a bolded @@ -144,7 +151,11 @@ def is_heading_paragraph(text: str) -> bool: both to build the section-jump navigation and to style headings distinctly in the diff view.""" text = text.strip() - return bool(text) and len(text) < 100 and not text.endswith((".", ":", ";")) and bool(_HEADER_LINE_RE.match(text)) + if not text or len(text) >= 100 or text.endswith((".", ":", ";")): + return False + if text.isdigit() or _BORING_HEADING_RE.match(text): + return False + return bool(_HEADER_LINE_RE.match(text)) def split_paragraphs(section_text: str): diff --git a/backend/app/main.py b/backend/app/main.py index 45d4407..8fb141d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from . import acquisition, db from .diffing import section_breakdown +from .extract import is_heading_paragraph FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" @@ -297,6 +298,13 @@ def company_diff(ticker: str, from_id: int, to_id: int): conn.close() chunks = json.loads(diff_row["chunks_json"]) + # Re-derive the heading flag with the current heuristic on every read, + # rather than trusting whatever was baked into chunks_json at ingest + # time -- cheap (a regex over already-loaded text), and it means a + # heuristic fix like excluding "Table of Contents" lines takes effect + # immediately for already-ingested filings, not just future ones. + for c in chunks: + c["heading"] = is_heading_paragraph(c["text"]) return { "ticker": ticker, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b9a8ba8..14f3273 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -76,6 +76,41 @@ def test_list_companies_and_detail_and_diff(test_db): assert diff["local_summary"] is None +def test_diff_reclassifies_toc_and_page_numbers_as_non_headings(test_db): + # Old-format stored data (or data ingested before the heading heuristic + # was fixed) can have `heading: true` baked into chunks_json for a + # "Table of Contents" line or a bare page number. The API must + # re-derive the flag on every read rather than trust the stored value. + import json as jsonlib + + conn = db.get_conn() + from_id, to_id = seed_company_with_diff(conn) + stale_chunks = [ + {"type": "unchanged", "text": "Table of Contents", "heading": True}, + {"type": "unchanged", "text": "24", "heading": True}, + {"type": "unchanged", "text": "Cybersecurity Risks", "heading": True}, + {"type": "unchanged", "text": "We face cyber threats.", "heading": False}, + ] + conn.execute( + "UPDATE diffs SET chunks_json = ? WHERE from_filing_id = ? AND to_filing_id = ?", + (jsonlib.dumps(stale_chunks), from_id, to_id), + ) + conn.commit() + conn.close() + + resp = _client().get(f"/api/companies/ACME/diff?from_id={from_id}&to_id={to_id}") + assert resp.status_code == 200 + chunks = resp.json()["chunks"] + headings = {c["text"]: c["heading"] for c in chunks} + assert headings["Table of Contents"] is False + assert headings["24"] is False + assert headings["Cybersecurity Risks"] is True + + titles = [s["title"] for s in resp.json()["section_breakdown"]] + assert "Table of Contents" not in titles + assert "24" not in titles + + def test_peer_percentile_appears_with_enough_sector_peers(test_db): conn = db.get_conn() from_id, to_id = seed_company_with_diff(conn, ticker="ACME", sector="Technology") diff --git a/backend/tests/test_extract.py b/backend/tests/test_extract.py index 5ec5a2c..d006463 100644 --- a/backend/tests/test_extract.py +++ b/backend/tests/test_extract.py @@ -78,6 +78,18 @@ def test_is_heading_paragraph(): assert not is_heading_paragraph("") +def test_is_heading_paragraph_excludes_toc_and_page_numbers(): + # Real-world artifacts: a repeated "Table of Contents" line and a bare + # page number both pass the general short/title-like shape test but + # aren't real risk-topic headings, and used to pollute the section-jump + # nav and section-breakdown grouping with junk entries. + assert not is_heading_paragraph("Table of Contents") + assert not is_heading_paragraph("TABLE OF CONTENTS") + assert not is_heading_paragraph("Contents") + assert not is_heading_paragraph("24") + assert not is_heading_paragraph("136") + + def test_split_paragraphs_drops_blank_lines(): section = "First.\n\n\nSecond.\n" assert split_paragraphs(section) == ["First.", "Second."] diff --git a/frontend/css/style.css b/frontend/css/style.css index 14e7a8c..fec5bf5 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -601,8 +601,23 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { margin-bottom: 20px; font-size: 0.88rem; } -.summary-stat { display: flex; flex-direction: column; gap: 2px; } -.summary-stat .num { font-family: var(--font-mono); font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; } +.summary-stat { display: flex; align-items: center; gap: 12px; } +.stat-icon { + flex: none; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-ui); + font-weight: 700; + font-size: 1rem; +} +.summary-stat.added .stat-icon { background: var(--added-bg); color: var(--added-border); } +.summary-stat.removed .stat-icon { background: var(--removed-bg); color: var(--removed-border); } +.stat-icon.neutral { background: var(--border); color: var(--text-faint); } +.summary-stat .num { font-family: var(--font-mono); font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; } .summary-stat.added .num { color: var(--added-border); } .summary-stat.removed .num { color: var(--removed-border); } .summary-stat .lbl { color: var(--text-faint); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.05em; } @@ -847,6 +862,29 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { .section-nav li a.active { border-left-color: var(--accent); color: var(--accent); font-weight: 600; background: var(--accent-soft); } .section-nav .empty-note { font-size: 0.78rem; color: var(--text-faint); } +/* ---------- Peer compare panel ---------- */ +.peer-compare { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px 18px; +} +.peer-compare h3 { + margin: 0 0 10px; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + font-weight: 700; +} +.peer-compare select { width: 100%; } +.peer-compare-table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.8rem; } +.peer-compare-table th, .peer-compare-table td { padding: 6px 4px; text-align: right; } +.peer-compare-table th:first-child, .peer-compare-table td:first-child { text-align: left; color: var(--text-faint); font-size: 0.72rem; } +.peer-compare-table th { font-family: var(--font-mono); color: var(--text-muted); border-bottom: 1px solid var(--border); } +.peer-compare-table td { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.peer-compare-note { margin: 10px 0 0; font-size: 0.7rem; color: var(--text-faint); line-height: 1.5; } + /* ---------- Diff view (the centerpiece) ---------- */ .diff-view { background: var(--bg-elevated); @@ -856,20 +894,55 @@ input[type="text"]:focus, input[type="password"]:focus, select:focus { margin-bottom: 40px; overflow: hidden; } +/* Each legend chip is styled as a miniature sample of its own chunk type + (same background tint, border, glyph) so the legend explains itself by + looking like the thing it's labeling, rather than an abstract swatch. */ .diff-legend { display: flex; - gap: 20px; - padding: 12px 24px; + gap: 10px; + padding: 14px 24px; border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.legend-chip { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 12px 5px 9px; + border-radius: 999px; font-size: 0.78rem; + font-weight: 600; + border: 1px solid var(--border); color: var(--text-muted); - flex-wrap: wrap; } -.diff-legend span { display: flex; align-items: center; gap: 6px; } -.legend-swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; } -.legend-swatch.added { background: var(--added-bg); border: 1px solid var(--added-border); } -.legend-swatch.removed { background: var(--removed-bg); border: 1px solid var(--removed-border); } -.legend-swatch.unchanged { background: transparent; border: 1px solid var(--border); } +.legend-glyph { + font-family: var(--font-ui); + font-weight: 700; + width: 14px; + height: 14px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + line-height: 1; +} +.legend-count { + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.72rem; + color: var(--text-faint); + padding-left: 3px; + border-left: 1px solid var(--border); + margin-left: 1px; + padding-left: 7px; +} +.legend-chip.added { background: var(--added-bg); border-color: var(--added-border); color: var(--added-text); } +.legend-chip.added .legend-glyph { background: var(--added-border); color: var(--bg-elevated); } +.legend-chip.removed { background: var(--removed-bg); border-color: var(--removed-border); color: var(--removed-text); } +.legend-chip.removed .legend-glyph { background: var(--removed-border); color: var(--bg-elevated); } +.legend-chip.unchanged { color: var(--text-muted); } +.legend-chip.unchanged .legend-glyph { background: var(--border); } .diff-body { font-family: var(--font-prose); diff --git a/frontend/js/company.js b/frontend/js/company.js index e17c337..54b8307 100644 --- a/frontend/js/company.js +++ b/frontend/js/company.js @@ -1,4 +1,4 @@ -import { fetchCompany, fetchDiff } from "./api.js"; +import { fetchCompanies, fetchCompany, fetchDiff } from "./api.js"; import { getApiKey } from "./state.js"; import { summarizeDiff } from "./llm.js"; import { countUp } from "./animate.js"; @@ -137,7 +137,7 @@ export async function renderCompany(container, ticker) { diffRegion.innerHTML = `
Couldn't load this comparison: ${escapeHtml(e.message)}
`; return; } - renderDiffRegion(diffRegion, ticker, fromPeriod, toPeriod, diff); + renderDiffRegion(diffRegion, ticker, company.sector, fromPeriod, toPeriod, diff); } fromSelect.addEventListener("change", () => { @@ -255,6 +255,78 @@ function renderSectionBreakdown(sections) { `; } +// Real peer comparison -- pick another same-sector company and see their +// own latest real analyst_metrics side by side. Reuses the same endpoints +// the rest of the page already calls; no new backend surface, no invented +// numbers. +function renderPeerComparePanel(ticker, sector) { + return ` +
+

Compare vs. a peer

+ +
+
`; +} + +async function wirePeerComparePanel(ticker, sector) { + const select = document.getElementById("peer-select"); + const result = document.getElementById("peer-compare-result"); + if (!select) return; + + try { + const data = await fetchCompanies(); + const peers = (data.companies || []) + .filter((c) => c.sector === sector && c.ticker !== ticker) + .sort((a, b) => a.ticker.localeCompare(b.ticker)); + select.innerHTML = `` + + peers.map((p) => ``).join(""); + } catch (e) { + select.innerHTML = ``; + return; + } + + select.addEventListener("change", async () => { + const peerTicker = select.value; + if (!peerTicker) { + result.innerHTML = ""; + return; + } + result.innerHTML = `
Loading ${escapeHtml(peerTicker)}…
`; + try { + const [thisCompany, peerCompany] = await Promise.all([fetchCompany(ticker), fetchCompany(peerTicker)]); + const thisLatest = thisCompany.periods.slice(-2); + const peerLatest = peerCompany.periods.slice(-2); + if (peerLatest.length < 2) { + result.innerHTML = `

${escapeHtml(peerTicker)} doesn't have enough filings to compare yet.

`; + return; + } + const [thisDiff, peerDiff] = await Promise.all([ + fetchDiff(ticker, thisLatest[0].filing_id, thisLatest[1].filing_id), + fetchDiff(peerTicker, peerLatest[0].filing_id, peerLatest[1].filing_id), + ]); + const tm = thisDiff.analyst_metrics; + const pm = peerDiff.analyst_metrics; + const rows = [ + ["Textual similarity", tm.similarity_score != null ? `${tm.similarity_score.toFixed(1)}%` : "—", pm.similarity_score != null ? `${pm.similarity_score.toFixed(1)}%` : "—"], + ["Fog readability", tm.fog_index_to?.toFixed(1) ?? "—", pm.fog_index_to?.toFixed(1) ?? "—"], + ["Item 1A length", tm.word_count_to?.toLocaleString() ?? "—", pm.word_count_to?.toLocaleString() ?? "—"], + ]; + result.innerHTML = ` + + + + ${rows.map(([label, a, b]) => ``).join("")} + +
${escapeHtml(ticker)}${escapeHtml(peerTicker)}
${label}${a}${b}
+

Each company's own most recent filing-over-filing comparison, fetched fresh — not the pair currently selected above.

`; + } catch (e) { + result.innerHTML = `

Couldn't load ${escapeHtml(peerTicker)}: ${escapeHtml(e.message)}

`; + } + }); +} + function renderLocalSummaryPanel(local) { if (!local) return ""; const date = new Date(local.generated_at); @@ -313,7 +385,7 @@ function setupScrollspy(diffBodyEl, sectionNavEl) { headingEls.forEach((el) => scrollspyObserver.observe(el)); } -function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) { +function renderDiffRegion(container, ticker, sector, fromPeriod, toPeriod, diff) { const s = diff.summary; const m = diff.analyst_metrics; const aiKey = getApiKey(); @@ -323,16 +395,25 @@ function renderDiffRegion(container, ticker, fromPeriod, toPeriod, diff) {
-
0
-
Sentences added
+
+
+
+
0
+
Sentences added
+
-
0
-
Sentences removed
+
+
+
0
+
Sentences removed
+
-
0
-
Paragraphs unchanged
+
+
+
0
+
Paragraphs unchanged
+
- ${diff.chunks.map((c, i) => ` -
- ${GLYPH[c.type]} - ${escapeHtml(c.text)} -
`).join("")} + ${buildDiffBody(diff.chunks)}
+