# The public holdings page (`holdings.html`, served as /holdings)

How the public table is built, what it shows when there is no data, and how to check it. Static files only. There is
no build step, no server code and no package dependencies. Files: `holdings.html`, `holdings.js`, styles in
`styles.css`.

## What it reads

Two of our own published files, in this order:

| Step | File | Used for |
|---|---|---|
| 1 | `data/holdings/latest.json` | the header: snapshot date, revision, run status and warnings, and for each fund its as-of date, row count and `source_url` |
| 2 | the `path` named by that pointer, for example `data/holdings/2026-09-12.json` | the rows themselves |

`latest.json` is deliberately row-free (see [holdings data schema](holdings-schema.md)), so the page reads it for the
context and then fetches the dated snapshot for the table. The pointer is fetched with `cache: 'no-store'` because its
URL is stable across days. The dated snapshot is fetched normally, because its bytes never change.

The page asks for those two files and nothing else. There is no analytics script on it, and no call to a third party.

## Ground rules the page enforces

1. **A row is only rendered if a fund publishes rows.** Rows come from funds whose `publishing_mode` is `rows`.
   A `link_only` fund contributes zero rows and is listed below the table with the reason and a link to the issuer's
   own page. (Today that is HLAL: Wahed's terms do not permit republication, so we store no HLAL rows at all. See the
   rights gate in [holdings pipeline](holdings-pipeline.md).)
2. **The source URL comes from the data file first.** Each row's link is the `source.url` recorded for its fund in the
   snapshot, so the link is the page the row was actually read from. Only if a file omits the URL does the page fall
   back to the fund's registered issuer page (`https://www.sp-funds.com/spus`, `https://www.wahed.com/hlal`).
   Since rows are constructed per fund, a row can only ever link to its own fund's page.
3. **Missing values stay missing.** A `null` weight renders as an em dash with a tooltip, never as `0`. Rows the
   issuer did not publish a usable value for sort last regardless of direction.
4. **Nothing is invented when data is absent.** A 404 on the pointer is treated as "not published yet" and shows an
   explanatory panel plus a waitlist form, never an empty table and never placeholder rows.
5. **Dates only, no times.** `as_of`, `ingested_on` and `retrieved_on` are dates by design, so the page shows dates.
   The page says so, rather than implying a wall-clock freshness it cannot prove.
6. **Only two URL shapes are fetched.** The pointer must match `^data/holdings/[A-Za-z0-9._-]+\.json$` and the
   snapshot must match `^data/holdings/\d{4}-\d{2}-\d{2}\.json$` before either is requested, so the page cannot be
   turned into a fetch-anything gadget. All data is rendered as text nodes (`textContent`), never as HTML.

## Behaviour

- **Table columns:** ticker, name, fund, weight percent, as-of date and source link.
- **Default sort:** weight, descending. Every header except Source is a `<button>`; clicking toggles
  ascending/descending, and the active `<th>` carries `aria-sort`. Rows the issuer left blank sort last either way.
  Ties break on ticker, then name, so the order is stable.
- **Filtering:** a fund `<select>` built from the funds that actually contributed rows (with per-fund counts), and a
  search box matching ticker, company name, CUSIP and fund ticker. Both run in the browser; "Clear filters" resets both.
- **Row count** is a polite live region: "Showing 412 of 503 rows (all funds)" and so on. When nothing matches it says
  so.
- **Also on the page:** the data-not-advice notice (above the table, visible before any fetch resolves), the per-fund
  summary table (status, holdings date, rows contributed, issuer link), the link-only fund panel, and a collapsible
  provenance block (the pointer and the snapshot it names, revision, run status, `content_sha256`, plus the
  `attribution`, `disclaimer` and rights notes carried inside the file).
- **Accessibility:** semantic table with a caption and `scope` attributes, a visually-hidden label for every control,
  sort state announced through `aria-sort`, keyboard-reachable controls, a sticky header row, and a `<noscript>` block
  that points at the static pages and the raw JSON for anyone without JavaScript.

## Empty, error and partial states

| Situation | What the page shows |
|---|---|
| `latest.json` missing (HTTP 404) | "No daily snapshot has been published yet" panel plus a waitlist form. Expected until the first scheduled run is committed. |
| Pointer or snapshot fails to parse, or a non-JSON answer | Error panel with the HTTP status or path, a "Try again" button, and a `file://` hint (browsers block local JSON reads). |
| `run.status` is not `ok`, or the run recorded warnings | A note above the table quoting the run status and up to eight raw warning strings. |
| A fund's status is `parse_failed` / `empty_source` / `source_unavailable` | The fund appears in the summary with that status and in the "funds not listed above" panel, with the retrieval date. No rows, no guesses. |

## Checking it (QA)

The page needs data to show a table, and it needs to be served over HTTP (a `file://` page cannot fetch JSON).

```bash
node tools/ingest/holdings.mjs            # writes data/holdings/YYYY-MM-DD.json + latest.json
node tools/ingest/validate.mjs data/holdings
python3 -m http.server 8080               # then open http://localhost:8080/holdings.html
```

Checklist once a snapshot exists:

1. Row count on the page equals the sum of `funds[].rows` for `publishing_mode: "rows"` funds in the snapshot.
2. The "As of" column matches each fund's `as_of` in the file, not the file name.
3. Clicking a row's source link opens the issuer page recorded for that row's fund.
4. Sorting the weight column descending puts the largest published weight first, with em-dash rows last.
5. Filtering by fund and searching a ticker both reduce the count line, and "Clear filters" restores every row.
6. Every fund with `publishing_mode: "link_only"` appears below the table with a link and no rows.

Dev override, for checking a pointer other than `latest.json` (only paths under `data/holdings/` are accepted):

```
holdings.html?src=data/holdings/latest.json
```

## The static pages the ingest run writes

`holdings.js` builds the table in the browser, so `holdings.html` itself ships no rows. Crawlers read the HTML source,
and so does anyone in a viewer without JavaScript, which is why the same snapshot is rendered to plain HTML as well:

| File | What it is |
|---|---|
| `holdings-full.html` | Every row we may publish, across all funds, one static table |
| `fund-spus.html` | One page per fund that publishes rows, named `fund-<ticker>.html` |

`tools/ingest/render-pages.mjs` writes them, right after the ingester, from `latest.json` and the dated snapshot it
names. No dependencies and no build step, and there is no clock in the output: the pages carry dates from the
snapshot and nothing that changes between runs, so an unchanged snapshot renders byte-identical files and writes
nothing.

The rules it enforces are the ones above, plus two of its own:

1. **Rows come only from `publishing_mode: "rows"` funds.** A `link_only` fund contributes no rows to any generated
   page, and gets no page of its own until it publishes rows. On `holdings-full.html` it appears only in the summary
   and the link-out block.
2. **A fund page that would now carry no rows keeps its URL but drops its rows.** It is rewritten as a short page
   saying the last run published nothing for that fund, with the reason and a link to the issuer. A fund that has
   never published rows gets no page at all, so nothing is created speculatively.

Each page carries the fund's own as-of date, the per-row issuer source link, the data-not-advice notice and the footer
from `holdings.html`, character for character. `tools/ingest/render-pages.test.mjs` asserts those strings are present,
along with the no-rows rule and byte-for-byte repeatability. The pages are not in `tools/copy/check.mjs`'s file list
yet (they are regenerated from templates, not edited by hand), so the test is what protects the fixed wording.

```bash
node tools/ingest/render-pages.mjs                         # write the pages from data/holdings/latest.json
node tools/ingest/render-pages.mjs --check --json          # fail if the pages on disk are out of date
node tools/ingest/render-pages.mjs --help                  # every flag
```

The run also maintains the block between `<!-- generated:pages:start -->` and `<!-- generated:pages:end -->` in
`sitemap.xml`: one entry per generated page, with the snapshot date as `lastmod`. Entries outside that block are
hand-written and left alone, and a sitemap without the markers is left untouched. `robots.txt` needs no change, since
everything public is already crawlable.

## Reference fixture

`tools/ingest/fixtures/page/latest.json` is a frozen copy of the `data/holdings/latest.json` pointer as it stood on
2026-09-12: the same `date`, `path` (`data/holdings/2026-09-12.json`), `content_sha256`, `revision` and per-fund
summary. `latest.json` is the file that moves every day, so freezing a copy keeps that snapshot reachable for page
checks after the daily run writes a newer day.

The dated half of the pair is the committed `data/holdings/2026-09-12.json` itself. Dated files never change, so the
fixture reads it in place rather than keeping a second 57 KB copy that could drift from the hash the pointer records.

Because the dev override only accepts a pointer whose path matches `data/holdings/[A-Za-z0-9._-]+\.json$`, the same
frozen pointer is also published at `data/holdings/fixture-2026-09-12.json`, which the page can load today:

```bash
python3 -m http.server 8080
# open http://localhost:8080/holdings.html?src=data/holdings/fixture-2026-09-12.json
```

Loading it as `holdings.html?src=fixtures/page/latest.json` needs the one-line widening of that allowlist in
`holdings.js` to also accept a `fixtures/page/` prefix. That edit was left as a follow-up rather than made blind:
`holdings.js` is about 28 KB, and the session that added this fixture could read only the first 20 KB of it. The two
files above are copies of the same pointer, so once the allowlist is widened the `fixtures/page/` path works with no
change to the fixture itself.

Checked against the 2026-09-12 snapshot on 2026-09-13: the page's rows are built from the `rows` fund (SPUS, 219 rows,
matching the pointer), each row renders the fund's own `as_of` (2026-09-11, not the snapshot date in the file name)
and links to the fund's recorded `source.url`, and the `link_only` fund (HLAL) is listed below the table with its
Wahed link and no rows. No page change was needed for the real data shape.

## Known limits

- The interactive table is rendered client-side. The crawlable rows live on the generated pages described above
  (`holdings-full.html`, `fund-<ticker>.html`), which `holdings.html` links to. The browser table itself is still
  assembled from JSON.
- The page fetches JSON from the same origin, so it works on any host. Cross-origin use needs the versioned feed with
  CORS headers (roadmap item), not this page.
- No CSV export and no API. Bulk redistribution is exactly what the SPUS rights review leaves waiting for written
  confirmation.
