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

How the public change log is built, what it shows when there is nothing to show, and how to check it. Static files
only. There is no build step and no server code, and nothing here depends on a package. Files: `changes.html`,
`changes.js`, styles in `styles.css`.

## What it reads

Two of our own published files, in this order:

| Step | File | Used for |
|---|---|---|
| 1 | `data/changes/index.json` | one small entry per comparison: `date`, `path`, `baseline_date`, `revision`, `data_status`, `run_status`, `funds[]`, `counts{}` |
| 2 | the `path` named by each entry, for example `data/changes/2026-09-11.json` | the rows: `funds[].entries[]` and `funds[].exits[]`, each with the fund's `source_url` |

The index is the pointer and the dated documents are the rows, the same split `/holdings` uses with `latest.json`.
The index is fetched with `cache: 'no-store'` because its URL is stable while its content moves. A dated document is
immutable in practice, so it is fetched normally. Those JSON files are the only requests the page makes. There is no
analytics script on it, and no call to a third party.

## Ground rules the page enforces

1. **A row exists only if a comparison document names it.** Entries and exits are read from `funds[].entries[]` and
   `funds[].exits[]`. The page never derives a change by comparing two snapshots in the browser, so what you see is
   what the differ published.
2. **The source URL comes from the document first.** The link on a row is the `source_url` recorded for its fund. Only
   if a document omits it 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 built per fund, a row can only link to its own fund's page.
3. **Free view is 7 days, measured in dates.** The window runs from the newest published comparison back seven
   calendar days, inclusive. Comparisons older than that are listed as counts only, in a locked block that points at
   the Alerts tier in `index.html#pricing`. No query parameter widens the window.
4. **A missing value stays missing.** A `null` weight renders as an em dash with a tooltip, never as `0`. A row with
   no usable weight sorts last regardless of direction.
5. **Nothing is invented when data is absent.** A missing index (HTTP 404) or an empty one shows an explanatory panel,
   and the panel's first line differs so a reader can tell "not published yet" from "published and empty". An empty log
   says "No changes observed yet". There is never a placeholder row.
6. **A test fixture is announced.** Any comparison whose `data_status` is not `live` raises a warning panel, is
   labelled in the ticker cell, and carries a `test fixture` pill in the per-day summary. Today's single committed
   comparison (`data/changes/2026-09-11.json`) is fixture-mode, built from the synthetic DEMO snapshots.
7. **Only two URL shapes are fetched.** The index must match `^data/changes/[A-Za-z0-9._-]+\.json$` and each dated
   document must match `^data/changes/\d{4}-\d{2}-\d{2}\.json$` before it is requested, so the page cannot be turned
   into a fetch-anything gadget. All data is rendered as text nodes (`textContent`), never as HTML.
8. **One bad comparison does not blank the page.** A dated document that fails to load is reported in the warning
   panel and listed in the per-day summary as "document not published here", while the days that did load still render.
   When every document in the window fails, the page shows the error panel instead.

## Behaviour

- **Table columns:** change date, change, ticker, name, fund, weight percent and source link.
- **Default sort:** change date, newest first. Every header except Source is a `<button>`; clicking toggles
  ascending/descending and the active `<th>` carries `aria-sort`. Ties break on date, then fund, then ticker.
- **Filters:** fund, direction (entries only, exits only) and a free-text search across ticker, name, fund and change
  date. All three run in the browser; "Clear filters" resets them.
- **Row count** is a polite live region, for example "Showing 3 of 3 rows (all funds, entries and exits)".
- **Weight semantics** are stated under the table: for an entry it is the weight on the day the ticker appeared, for
  an exit it is the last weight the fund published for that name. An exit row also shows the date the name was last
  seen when the comparison records it.
- **Change days in the free window** is a second, smaller table built from the index alone: change date (linked to the
  dated JSON), the date compared with, the funds involved, entered and left counts, what else the comparison recorded
  (weight shifts, ticker renames, rows with no published weight, identity changes) and the run status. Weight shifts
  are shown as counts only, because the shifts themselves with both weights are part of the Alerts tier.
- **Locked block:** comparisons older than the window are listed with their date, the date compared with, and their
  entered and left counts straight from the index, each with a `Locked` pill, above a link to `index.html#pricing`.
  When nothing is older, the block says so and the call to action is hidden rather than teasing a tier for nothing.
- **Also on the page:** the data-not-advice notice above the table (visible before any fetch resolves), the run
  warning panel, and a collapsible provenance block (index file, days published, window, schema version, producer,
  timezone, plus the `change_note`, `disclaimer` and `attribution` carried inside the index).
- **Accessibility:** semantic tables with captions and `scope` attributes, a visually-hidden label on every control,
  sort state announced through `aria-sort`, sticky header row, keyboard-reachable controls, and a `<noscript>` block
  that points at the raw JSON and the holdings page.

## Empty, error and partial states

| Situation | What the page shows |
|---|---|
| `data/changes/index.json` missing (HTTP 404) | "The change log has not been published yet" panel plus a waitlist form. |
| Index present, `entries: []` | "No changes observed yet" panel plus a waitlist form. |
| Index has entries, none inside the 7-day window | The locked block only, plus a window line saying no change day falls inside the window. |
| Window days loaded, but they recorded no entries or exits | "No entries or exits in the last 7 days" panel, the per-day summary, and no table. |
| A dated document 404s or will not parse | Warning panel entry plus "document not published here" in the per-day summary. The other days still render. |
| Every dated document in the window fails | Error panel with the first failure's message, a "Try again" button, and a `file://` hint. |
| Index entry with a bad `date` or an unexpected `path` | The entry is skipped and listed in the warning panel. It is never fetched. |
| `data_status` is not `live`, or `run_status` is not `ok` | Warning panel naming the date, the label and up to eight raw `warnings` strings. |

## Checking it (QA)

The page needs the index and at least one dated document, and it must be served over HTTP (a `file://` page cannot
fetch JSON).

```bash
python3 -m http.server 8080
# open http://localhost:8080/changes.html
```

Checked against the committed DEMO fixture on 2026-09-13 (`data/changes/2026-09-11.json`, from the synthetic
snapshots in `tools/diff/fixtures/snapshots/`), and the results the page must reproduce:

1. Three rows, newest first, all dated 2026-09-11: `HOTE` (Entered, DEMO, 0.45%), `GOLF` (Left, DEMO, 0.3%,
   last published as of 2026-09-09) and `NOVEM` (Left, DEMO2, 0.2%, last published as of 2026-09-09).
2. The warning panel names 2026-09-11 as a test fixture, and the run warning `fixture_inputs:2026-09-10->2026-09-11`.
3. Every row links to the source URL in the document, `https://safastack.com/tools/diff/fixtures/README.md`.
4. The per-day summary reads: compared with 2026-09-10, funds DEMO and DEMO2, 1 entered, 2 left, and the extra counts
   (4 weight shifts, 1 ticker rename, 1 row with no published weight).
5. The locked block reports that nothing is locked, because the only published day is inside the window.
6. Filtering to DEMO leaves two rows, "Exits only" leaves two, and "Clear filters" restores three.

Dev override, for checking an index other than the default (only paths under `data/changes/` are accepted):

```
changes.html?src=data/changes/index.json
```

## Deployment note

`.vercelignore` excluded the whole of `data/changes/` while the only document in it was fixture-mode DEMO data with
invented funds, because a git-connected Vercel build would otherwise have published it at a public URL. Now that this
page reads the directory, that exclusion has to be narrowed: `data/changes/index.json` and the dated documents have to
be reachable, and the DEMO fixture should come out of the deploy on the run that replaces it with a live comparison
(`node tools/diff/changes.mjs --prune-fixtures`). Until that pruning lands, the fixture document is served and the
page labels it as a test fixture on every row and in the warning panel.

## Known limits

- Entries and exits only. Weight shifts, renames and rows with no published weight are counted in the per-day summary,
  not listed row by row; the detailed shift list is part of the Alerts tier.
- The page reads one dated document per comparison in the window. Seven documents is the most it will ever fetch for
  the free view, which keeps it cheap, but it also means the free history is bounded by design.
- No CSV export and no API on this page. Bulk redistribution waits on written permission from the issuers.
