# Broadsheet → App (`app/admin`) — Tracer Bullet & Slice Tracker

> Single source of truth for the end-of-term broadsheet and the extra-result broadsheet in the ZAP app. Each has its own engine, shared by app and desktop.
> Every session: read this whole file first, do the **next unchecked slice**, then update the tracker + session log before ending.
> Class-result S0–S5 and extra-result E0–E3 are done. Do not re-litigate §2 or §9.2.

---

## 0. Session protocol (read every time)

1. Read this file top to bottom. Do not re-litigate the **Locked decisions** (§2 for class result, §9.2 for extra result) — they were settled in grilling sessions.
2. Read the `zap-app` skill (`.cursor/skills/zap-app/SKILL.md`) before touching any `app/admin` UI.
3. Pick the **first unchecked slice** in §6 whose dependencies are checked. One session = one slice. S0–S5 and E0–E3 are done.
4. When done: tick the box, fill in "Done in", append to the **Session log** (§8), and record any new fact in **Discoveries** (§7).
5. Never edit `app/public_html/zap_app/admin/` (stale deploy snapshot).
6. No DB schema changes. If a slice seems to need one, stop and record it in §7 as a blocker.

---

## 1. Goal

Staff open a class broadsheet from the app's **EOT — Summary** page (`app/admin/summary.php`):

1. A **Broadsheet** button sits beside **Check result** in the Summary toolbar.
2. It opens a modal (`#fullEmptyModal`) with a Session → Term → Class → Subclass cascade, same behaviour as desktop `admin/admin_broadsheet.php`.
3. **Get broadsheet** navigates to a native page `app/admin/broadsheet.php` — DataTable with a DataTables **Print** button.
4. Print opens `app/admin/broadsheet-print.php` in the **system browser** via the `browser:` scheme (signed short-lived token), which auto-prints.

The data comes from one shared engine (`admin/includes/broadsheet_engine.php`) that the desktop `admin/print_blank.php?print=broadsheet` branch is also switched onto (S5).

---

## 2. Locked decisions

| # | Decision |
|---|---|
| Q1 | **Roles** = Summary audience. Full access (all classes, "All Subclasses" option): `director`, `backend` (table names), `admin`, `academic_admin`, `principal`, `academic_principal` (staff status — any status containing `principal`). **Form teachers** (`status = academic` + `form = form`): only their own class/subclass via `form_staff_class_subclass`. Non-form academics: no access. Enforced **server-side** on every endpoint and page, not just by hiding the button |
| Q2 | Native page `app/admin/broadsheet.php` (zap-app styling) — **not** the legacy `print_blank.php` |
| Q3 | Kill-switch: `$school_config['block_check_result'] == 'yes'` → button disabled with "Blocked" chip; page/print refuse with "Broadsheet deactivated." (`block_publishing` already blocks Summary itself) |
| Q4 | Sessions/terms/classes come from **any `result_main` rows** (not only generated). **No remark results** |
| Q5 | Final feature keeps all legacy term options: 1st Term, 2nd Term (only), 1st & 2nd Term Cumulative, 3rd Term (only), Annual; plus Annual "term breakdown". Delivered over slices (§6) |
| Q6 | DataTables **Buttons** extension with a single **Print** button. Its `action` opens the `browser:` print link (no `buttons.print` in the WebView) |
| Q7 | "All Subclasses" option only for full-access roles; form teachers only see their own subclass |
| Q8 | Tracer bullet = single-term broadsheet end-to-end (§5) |
| Q9 | This doc: `docs/broadsheet-tracer-bullet.md` |
| Q10 | Print page `app/admin/broadsheet-print.php`, standalone like `cbt-print-results.php`. Auth: signed `pt=` token (preferred — the system browser does not share the app session, and an unrelated login already in that browser must not 403 the token holder) **or** the browser's own app session when there is no token. Token helper `app/admin/includes/broadsheet-app-print-token.php` (same secret derivation as `cbt-app-print-token.php`, salt `zap-broadsheet-print-v1`; claims `t,u,c,s,sess,term,d,e`; TTL 2h; token tables `staff, backend, admin, director`). Role + form scope re-checked against DB on every print |
| Q11 | **No Excel** — Print is the only button |
| Q12 | In-app table: horizontal scroll, **sticky first column** (student name) via CSS `position: sticky`, short horizontal headers (`subject_short`). Rotated headers only on the print page. No DataTables Responsive |
| Q13 | Modal body served by `broadsheet.php` (`get_broadsheet_modal` POST, same shape as Summary's `get_check_result_modal`). Cascade endpoints on `broadsheet.php`: `bs_get_term`, `bs_get_class`, `bs_get_subclass`. All field names/classes prefixed `bs_` so Summary's Check result handlers (`[name=sess]`, `[name=term]`, `.load_term`, …) never fire |
| Q14 | Broadsheet toolbar: Back (→ Summary) + Reload. "Change" (reopen picker in-page) is slice **S4** |
| Q15 | Position column only when `should_position_show_in_broadsheet == 'yes'`. Reg no column only when `$_SESSION['school_id'] == 29` (legacy parity) — computed in **one** `$show_reg_no` line in the engine so it can become a setting later |
| Q16 | Summary button: label "Broadsheet", icon `fa-table`, `md-btn-contained md-btn-contained--tonal md-ripple md-gen-toolbar__btn`, side by side with Check result (which stays `--success`), 50/50 on narrow screens |
| Q17 | Shared engine `admin/includes/broadsheet_engine.php` returns **arrays, never HTML**; prepared statements / int casts; owns role-scope checks |
| Q18 | Page layout = Pattern C (context hero) — see §4 |
| Q19 | Empty/error states — see §4.4 |
| Q20 | Slice order S0 → S5 (§6). **S5 (desktop switch onto the engine) is required**, not optional |

---

## 3. Architecture

### 3.1 Files

| File | New/Edit | Responsibility |
|---|---|---|
| `admin/includes/broadsheet_engine.php` | new | Scope resolution, cascade lookups, broadsheet data build. Shared by app + desktop |
| `app/admin/broadsheet.php` | new | AJAX endpoints (modal + cascade) and the broadsheet page |
| `app/admin/broadsheet-print.php` | new | Standalone print page for the system browser |
| `app/admin/includes/broadsheet-app-print-token.php` | new | Mint/parse signed `pt=` tokens + build `browser:` link |
| `app/admin/summary.php` | edit | Toolbar button. Picker JS lives in `zap-broadsheet-picker.js` |
| `app/admin/scripts/zap-broadsheet-picker.js` | new (S4) | Shared modal open, cascade, and submit (Summary + broadsheet page) |
| `app/admin/styles/zapapp.css` | edit | `/* ── zap-app: broadsheet ── */` block scoped to `.broadsheet-page` + Summary toolbar 2-button layout |
| `admin/print_blank.php` | edit (S5) | Broadsheet branch reads from the engine |
| `admin/admin_broadsheet.php` | edit (S5) | Cascade endpoints read from the engine (also fixes the `$session_term` bug, §7) |

### 3.2 Engine API (`admin/includes/broadsheet_engine.php`)

Conventions (same as the CBT engine): functions take `mysqli $db, array $ctx, ...`; never read `$_POST`/`$_GET`, never echo/exit; return `['ok'=>bool, 'data'=>..., 'error'=>string, 'code'=>'forbidden'|'not_found'|'bad_request'|'blocked']` or plain arrays for lists.

```php
// $ctx = ['table_name' => ..., 'status' => ..., 'form' => ..., 'user_id' => int]
broadsheet_ctx_build($table_name, array $info, $user_id): array
broadsheet_scope(mysqli $db, array $ctx): array
    // ['level' => 'all'|'form'|'none', 'pairs' => [[class_id, subclass_id], ...]]  (pairs only for 'form')
broadsheet_sessions(mysqli $db, array $ctx): array              // ['2025/2026', ...] newest first
broadsheet_terms(mysqli $db, array $ctx, $sess): array          // [term_key => label]
broadsheet_classes(mysqli $db, array $ctx, $sess, $term_key): array      // [class_id => class_name], class_order
broadsheet_subclasses(mysqli $db, array $ctx, $sess, $term_key, $class_id): array
    // [['id' => 'all'|int, 'name' => ...]]; 'all' only when scope level == 'all'
broadsheet_build(mysqli $db, array $ctx, $class_id, $subclass_id, $sess, $term_key): array
    // ok => data: [
    //   'title', 'class_name', 'subclass_name', 'sess', 'term', 'term_key', 'mode' => 'term'|'cumulative'|'annual',
    //   'subjects' => [subject_id => subject_short]  (sorted by name),
    //   'rows' => [[student_id, name, reg_no, scores => [subject_id => float|null], total, average, position, position_sort]],
    //   'show_position' => bool, 'show_reg_no' => bool,
    //   'breakdown' => null | [...]   (S3)
    // ]
```

**Term keys** (kept identical to legacy so S5 is a drop-in): `1st Term`, `2nd Term Only`, `2nd Term Cumulative`, `3rd Term Only`, `3rd Term Cumulative` (= Annual). `broadsheet_term_parse($term_key)` → `['term' => '2nd Term', 'mode' => 'term'|'cumulative'|'annual']`.

**Scope rules** (`broadsheet_scope`):
- `table_name` in `director`, `backend` → `all`.
- staff `status` in `admin`, `academic_admin` or containing `principal` → `all`.
- staff `status = academic` and `form = form` → `form` (app audience); pairs from `form_staff_class_subclass WHERE staff_id = ?`.
- **desktop audience only:** staff `status = academic` → `subject`; pairs from `staff_subject_subclass`. Checked before the form-teacher rule, so the app path is unchanged. `'all'` is allowed when they teach that class.
- else → `none` (every engine function returns `code = forbidden`).
- `broadsheet_build` with `subclass_id = 'all'` requires `all` or `subject` (a class they teach). With `form`, `(class_id, subclass_id)` must be in pairs.

**Kill-switch:** every public engine function returns `code = blocked` when `block_check_result == 'yes'`.

**Hardening:** all IDs int-cast; session validated with `check_session()`, term via `check_term()`; every query a prepared statement. Values arrive via `encrypt()`, which is only reversible encoding (`urlencode(base64×3)`), so **never trust a decoded ID without the scope check**.

### 3.3 Request flow

```
summary.php  [Broadsheet btn] ──POST get_broadsheet_modal──▶ broadsheet.php → modal HTML
  modal  bs_session change ──POST bs_get_term─────────▶ broadsheet.php → broadsheet_terms()
         bs_term change    ──POST bs_get_class────────▶ broadsheet.php → broadsheet_classes()
         bs_class change   ──POST bs_get_subclass─────▶ broadsheet.php → broadsheet_subclasses()
         [Get broadsheet]  ──GET broadsheet.php?class=&subclass=&session=&term= (loadPage)
broadsheet.php page → broadsheet_build() → hero + DataTable
  [Print] DT button action → window.open('browser:https://host/app/admin/broadsheet-print.php?...&pt=TOKEN', '_self')
broadsheet-print.php → session OR pt token → broadsheet_build() → rotated-header table → window.print()
```

All POSTs carry `csrf_token` (`$security->create_csrf_token()` / `check_csrf_token()`), same as Summary.

---

## 4. UI spec (zap-app)

### 4.1 Summary toolbar button (`app/admin/summary.php` ~660)

```html
<div class="md-gen-toolbar__actions md-gen-toolbar__actions--2">
  <a href="#" data-menu="fullEmptyModal" class="md-btn-contained md-btn-contained--success md-ripple md-gen-toolbar__btn openModal open_check_result_modal">
    <i class="fa fa-search" aria-hidden="true"></i> Check result
  </a>
  <a href="#" data-menu="fullEmptyModal" class="md-btn-contained md-btn-contained--tonal md-ripple md-gen-toolbar__btn openModal open_broadsheet_modal">
    <i class="fa fa-table" aria-hidden="true"></i> Broadsheet
  </a>
</div>
```

- `--2` modifier: 2-column grid, equal widths, gap from tokens (add in `zapapp.css` next to the existing `md-gen-toolbar__actions` rules).
- Blocked (`block_check_result == 'yes'`): render a disabled `<button>` + `<span class="md-chip md-chip--error">Blocked</span>`, no `openModal`.
- Only render for roles with scope ≠ `none` (Summary already limits who arrives; non-form academics never see it).

### 4.2 Broadsheet modal (served by `get_broadsheet_modal`)

Reuse the Check result sheet markup so it looks identical: wrapper `md-sl-form md-summary-check-sheet md-broadsheet-sheet`, intro header `md-check-pick__intro` (icon `fa-table`, title "Broadsheet", lead "Pick a session, term and class."), fields as `mui-field mui-field--outlined md-admit-field md-check-pick-field` with `selectpicker md-check-pick-select` (`data-container="#fullEmptyModal"`, `data-width="100%"`, `data-style="btn md-check-pick-select__btn"`) and `md-admit-spinner` loaders.

| Field | `name` / id | Spinner class |
|---|---|---|
| Session | `bs_session` / `summary_bs_session` | — (options rendered server-side) |
| Term | `bs_term` / `summary_bs_term` | `bs_load_term` |
| Class | `bs_class` / `summary_bs_class` | `bs_load_class` |
| Subclass | `bs_subclass` / `summary_bs_subclass` | `bs_load_subclass` |

Submit: `md-btn-contained md-ripple bs_get_broadsheet md-check-pick__submit` ("View broadsheet", `fa-eye`). Footer: tonal Close, same as Check result.
Validation order (notify each): session → term → class → subclass. On success: `loadPage(); window.location.href = 'broadsheet.php?class=…&subclass=…&session=…&term=…'` (values are already `encrypt()`ed by the server; pass through `encodeURIComponent`).

Picker JS lives in `scripts/zap-broadsheet-picker.js` (included by Summary and the broadsheet page). `$(".open_broadsheet_modal")` mirrors `open_check_result_modal` (overline "Results", spinner, inject title/body/footer). Delegated `#fullEmptyModal` change handlers on `[name=bs_session]`, `[name=bs_term]`, `[name=bs_class]` → `url: "broadsheet.php"`. Each change resets the downstream selects to the `refresh` option and refreshes the selectpicker. A Change button may POST the current `bs_session` / `bs_term` / `bs_class` / `bs_subclass` with `get_broadsheet_modal`; the endpoint selects those options when they are in the caller's lists.

### 4.3 Broadsheet page (`app/admin/broadsheet.php`) — Pattern C

```html
<div class="md-page class-result-page broadsheet-page">
  <div class="card cards card-style card-filter md-toolbar md-cr-toolbar" id="broadsheet-toolbar" role="toolbar" aria-label="Broadsheet">
    <div class="content md-cr-toolbar__inner">
      <div class="md-cr-toolbar__brand">
        <a onclick="loadPage()" href="summary.php" class="md-cr-toolbar__back md-ripple" aria-label="Back to summary"><i class="fa fa-arrow-left"></i></a>
        <h1 class="md-cr-toolbar__title">Broadsheet</h1>
        <button type="button" class="md-cr-toolbar__reload md-ripple" onclick="reloadPage(this)" aria-label="Reload page"><i class="fa fa-sync-alt"></i></button>
      </div>
      <div class="md-broadsheet-toolbar__actions" id="broadsheet-actions">
        <!-- Change (tonal, fa-sliders-h, open_broadsheet_modal) then DT Print appended here -->
      </div>
    </div>
  </div>
  <div class="page-content md-feed class-result-feed broadsheet-feed">
    <!-- partials/zap-context-hero.php -->
    <!-- md-search-field#broadsheet-search -->
    <!-- md-card.md-broadsheet-card > .md-broadsheet-scroll > table#broadsheet-table -->
  </div>
</div>
```

- Before `header.php`: `$zap_load_datatables = true;` then load `/admin/assets/plugins/datatables/dataTables.buttons.min.js` (with `get_v()` cache-buster) after DataTables core. No `buttons.html5`, `jszip`, `pdfmake`, `buttons.print`.
- Context hero (`$zap_hero`): overline `Broadsheet`, title `class + subclass` (or `… — All subclasses`), icon `table`, stat = student count (label `Students`), pills Session / Term (label from term key) / Subjects (count). **Do not** repeat class name anywhere else.
- Search: `md-search-field md-search-field--result` → `table.search(this.value).draw()`. DT `dom: 'Brt'` (hide DT filter/info/paging).
- DataTable: `paging: false`, `order: [[0, 'asc']]`, `autoWidth: false`, all columns sortable; position cell `data-order="<position_sort>"`; missing score renders `–` with `data-order="-1"`.
- Buttons: single `{ text: '<i class="fa fa-print"></i> Print', className: 'md-btn-contained md-btn-contained--success md-ripple', action: () => window.open(PRINT_URL, '_self') }`; then `table.buttons().container().appendTo('#broadsheet-actions')`. `PRINT_URL` = server-built `broadsheet_print_link(...)` (includes `pt`).
- Table CSS (scoped `.broadsheet-page`): wrapper `overflow-x: auto; -webkit-overflow-scrolling: touch`; first column `position: sticky; left: 0; z-index: 2; background: var(--md-surface)` (header cell `z-index: 3`); compact cells, tabular numbers, header row uses `subject_short`; total/average/position columns get a subtle tonal background.
- Measure toolbar → set `--md-class-result-toolbar-offset` on `.broadsheet-page` (same snippet as Summary). Stagger `--md-delay` 0 (hero) → 1 (search) → 2 (card).
- `aria-label`s on hero, search, table card.

### 4.4 Empty / error states

| Situation | Behaviour |
|---|---|
| Cascade returns nothing | `notify({text: msg}, "error")` — "No broadsheet for this session" / "…for this term" / "No class available"; reset downstream selects |
| Page: build OK but zero rows | `md-gen-empty md-animate-in` card: "No submitted results for this class and term." Print button not rendered |
| Scope `forbidden` (e.g. form teacher edits URL) | Page shows `md-gen-empty`: "You do not have access to this broadsheet." + tonal button back to Summary. AJAX returns `{status:"failed", msg:...}` |
| Bad/tampered params | "Link incorrect." (page) / `{status:"failed"}` (AJAX) |
| `block_check_result == 'yes'` | Button disabled + chip; page/print: "Broadsheet deactivated." |
| Print: token expired | "This print link has expired. Go back to the app and tap Print again." |
| Print: no session, no token | "Sign in required to print this broadsheet." |
| Print: phone browser logged in as someone else | A valid `pt` token still prints. The system browser does not share the app session; an unrelated Chrome login must not 403 the token holder. |

### 4.5 Print page (`app/admin/broadsheet-print.php`)

- Bootstrap: `require includes/init.php` (not `authenticate.php`), then `includes/broadsheet-app-print-token.php`. A valid `pt` token wins over whatever session the phone browser already has; session is only the fallback when there is no token. Re-read staff `status/form` from DB for token holders.
- Token claims must match the request's class/subclass/session/term → else "This print link is invalid."
- Markup: plain white page, school name + title, table with **rotated headers** (port legacy `th.rotate` CSS from `print_blank.php` ~2142), empty score cell = short rule, `@page { size: landscape; margin: 10mm; }`. Small top bar with a Print button (`rs-bar` style from `cbt-print-results.php`) hidden in `@media print`; `setTimeout(window.print, 300)` on load.
- `Cache-Control: no-store`.

---

## 5. Tracer bullet (S0 — do this first)

Thinnest end-to-end slice proving every layer: **single-term broadsheet** (`1st Term`, `2nd Term Only`, `3rd Term Only`).

1. `admin/includes/broadsheet_engine.php`
   - `broadsheet_ctx_build`, `broadsheet_scope`, `broadsheet_term_parse`.
   - `broadsheet_sessions`, `broadsheet_terms` (S0 lists all five keys as legacy does, but `broadsheet_build` returns `code = bad_request` + msg "Coming soon" for `2nd Term Cumulative` / `3rd Term Cumulative` until S1/S2 — the modal hides those options while that guard exists), `broadsheet_classes`, `broadsheet_subclasses`.
   - `broadsheet_build` — termly branch only; hardened port of `print_blank.php` ~1742–1773 (also used for `3rd Term Only`, ~1651–1683): subjects from `result_subject` (`status='submit'`), per-student totals, `result_main.total/average/position_subclass` (or `position_class` for `all`).
2. `app/admin/includes/broadsheet-app-print-token.php` — `broadsheet_print_token_mint/parse`, `broadsheet_print_link($class_id, $subclass_id, $sess, $term_key)`.
3. `app/admin/broadsheet.php` — `require includes/authenticate.php`; AJAX: `get_broadsheet_modal`, `bs_get_term`, `bs_get_class`, `bs_get_subclass`; page per §4.3.
4. `app/admin/summary.php` — button (§4.1) + modal JS (§4.2).
5. `app/admin/broadsheet-print.php` — §4.5.
6. `zapapp.css` — `/* ── zap-app: broadsheet ── */` + Summary `--2` actions.

**Manual test matrix** (on a school with results for the current session):

| Role | Expect |
|---|---|
| admin / backend / director / principal | All classes; "All Subclasses" present; Print opens system browser and prints |
| Form teacher | Only own class + subclass; no "All Subclasses"; URL edited to another class → "You do not have access…" |
| Non-form academic | No Summary tile → no button; direct `broadsheet.php` hit → forbidden |
| `block_check_result = yes` | Button disabled + chip; direct URL → "Broadsheet deactivated." |
| Print link after 2h / tampered `pt` | Expired / invalid message |

**Parity check:** for 3 class/term combos, compare the app table (names, each subject score, total, average, position) to desktop `print_blank.php?print=broadsheet` for the same params. Must match cell-for-cell.

**Exit criteria:** every row of the test matrix passes; parity matches; no SQL string interpolation in the engine; Summary's Check result still works (its selects unaffected by `bs_` handlers).

---

## 6. Slices & progress tracker

Legend: `[ ]` todo · `[~]` in progress · `[x]` done · `[!]` blocked (see §7)

| ✓ | Slice | Depends | Done in |
|---|---|---|---|
| [x] | **S0** Tracer bullet — single term (1st, 2nd only, 3rd only), §5 | — | 2026-10-07 (device-only checks pendinreviewg, see §8) |
| [x] | **S1** 1st & 2nd Term Cumulative — engine branch (port `print_blank.php` ~1684–1741: per-subject average across both terms, average, positions via `get_position()`); unhide option; title `1ST TERM & 2ND TERM CUMULATIVE BROADSHEET OF …` | S0 | 2026-10-07 |
| [x] | **S2** Annual (compact) — engine branch (port ~1583–1650: `cumulative_total/average`, `cumulative_position_class/subclass`, honour `annual_only_last_subjects`); unhide option; hero pill "Annual" | S0 | 2026-10-07 |
| [x] | **S3** Annual term breakdown — engine fills `breakdown` (1st/2nd/3rd/Total per subject from `subject_term_total`); app: Pattern G sort strip toggle "Compact / Term breakdown" swapping two tables (breakdown uses 2-row grouped header, sticky name column); print page honours `&view=breakdown` | S2 | 2026-10-07 |
| [x] | **S4** "Change" button on broadsheet page — toolbar action (tonal, `fa-sliders-h`) reopens the same modal (`get_broadsheet_modal` + `bs_*` handlers moved to `scripts/zap-broadsheet-picker.js`, shared by Summary and broadsheet page); preselect current values | S0 | 2026-10-08 |
| [x] | **S5** Desktop on the shared engine (**required**) — `admin/admin_broadsheet.php` cascade endpoints call `broadsheet_terms/classes/subclasses` (desktop keeps its legacy scope: plain `academic` = classes they teach via `staff_subject_subclass` — add scope level `subject` to the engine for desktop only); `admin/print_blank.php` broadsheet branch builds `$names/$subject_total/$main_*/$title/...` from `broadsheet_build()` and keeps its markup byte-identical. Parity: capture desktop HTML table + AJAX JSON before/after for admin, academic, principal on termly / cumulative / annual → `diff` clean | S1, S2, S3 | 2026-10-08 |
| [x] | **E0** Extra-result tracer — percent only (`total_100`). Summary button, category cascade, page, print, engine, token. §9.5 | — | 2026-10-08 |
| [x] | **E1** Convert — `total_100` ↔ `total_normal` on the app sheet; Print carries the mode. Average is not recomputed. §9.6 | E0 | 2026-10-08 |
| [x] | **E2** Change button — reopen the picker with session, term, category, class, and subclass selected. §9.6 | E0 | 2026-10-08 |
| [x] | **E3** Desktop on the extra-result engine (**required**) — cascade + `print_blank.php?print=broadsheet-extra-result` read the engine; markup stays, including Excel and Convert. §9.6 | E1 | 2026-10-08 |

---

## 7. Discoveries

- **Legacy cascade bug:** `admin/admin_broadsheet.php` `get_term`/`get_class` guard with `!empty($session_term)` — a global (`admin/includes/initialize.php:117`, the `session_term` table), not the query result. So "No Broadsheet Available" never fires; users get an empty dropdown. Engine returns empty arrays; endpoints must check those. Fixed on desktop in S5.
- **Legacy decrypt mismatch:** `print_blank.php` decodes `class`/`subclass`/`term` with `decrypt_from_url()` but `session` with `decrypt()`. Use one function in the app; verify both decode `encrypt()` output identically before S5.
  - *Resolved (S0):* they differ. `decrypt()` runs `urldecode()` first, `decrypt_from_url()` does not. `encrypt()` output is already urlencoded, so the legacy desktop link (raw concatenation) reaches `$_GET` as plain base64 → `decrypt_from_url()` is right; `decrypt()` would turn any `+` into a space. The legacy `session` param only survives because session strings never produce `+`. **App rule:** every encrypted value in a URL is encoded once more (`encodeURIComponent` in JS, `rawurlencode` in PHP) and always read with `decrypt()`; POST values use `decrypt()` too. S5 must keep the desktop's raw links + `decrypt_from_url()` (or switch both sides together).
- **Legacy Total column** is `array_sum()` of the student's displayed subject scores, **not** `result_main.total`; students with no `result_main` average for that class/subclass/term are dropped. The engine matches both (parity run S0: 10 class/term/subclass combos on the demo DB, cell-for-cell identical).
- **Legacy title** for "All Subclasses" has a double space (`BROADSHEET OF JSS 1  FOR …`, empty subclass name in `space_items`). Kept in `title` for S5 byte parity; the app hero builds its own title.
- **Form teacher of a class without subclasses** (`form_staff_class_subclass.subclass_id` 0/NULL): legacy lists only "All Subclasses" (it skips empty subclass ids), which Q7 denies form teachers. Engine offers subclass id `0` labelled "Whole class" for them; it filters `subclass_id = 0` with subclass positions.
- **Director/backend `$info['status']` is `'admin'`** (init.php), so `broadsheet_scope` decides on `table_name` first; the table-`admin` executive (Universal Admin) gets `all` via status. `owner`/`account` executives → `none`.
- **Token-only print (no app session in the system browser):** `$_SESSION['school_id']` is missing, so `broadsheet-print.php` looks it up from `zeonglob_schools.schools` by database name (only used for the reg-no column). The database itself is the default `$pdo_db_name` in that case — same limitation as the CBT print.
- **App DataTables sort icons** use Glyphicons, which the app doesn't load (rendered as boxes). The broadsheet CSS overrides `th.sorting*::after` with Font Awesome glyphs.
- **Page shell** is `md-page class-result-page generate-page broadsheet-page` (spec said without `generate-page`): `generate-page` gives the same compact hero pills as Summary and the full `md-gen-empty` card styling.
- `get_broadsheet_modal` checks CSRF (Summary's `get_check_result_modal` sends the token but never checks it).
- **Legacy role gates:** `admin_broadsheet.php` `kick_out(... "admin/academic_admin/backend/principal/academic_principal/academic")`; `print_blank.php` page-level gate is wider (`academic/academic_principal/account/admin/academic_admin/academic_account/sub-admin/backend`) but the broadsheet branch is only linked from `admin_broadsheet.php`.
- **App DataTables:** `header.php` loads DataTables core only (`$zap_load_datatables`); no Buttons anywhere in `app/admin`. Buttons files live in `/admin/assets/plugins/datatables/`.
- **`summary.php` has no own role gate** — relies on nav visibility. `broadsheet.php` must not copy that; scope check is mandatory.
- **Reg no hack:** `$show_reg_no = ($_SESSION['school_id'] == 29)` in `print_blank.php` ~3293.
- **Legacy cumulative (S1) semantics**, all mirrored by `broadsheet_build_cumulative()`: subject list spans both terms for the whole class (no subclass filter); the student query's subclass filter applies to both terms, so a student who moved subclass between terms only gets the term spent in the chosen subclass; subject score = `round(sum / distinct terms, 2)`, a NULL term total counts as 0 (PHP `+= null`) yet still counts as a term; average = `round(mean of subject scores, 2)`; positions from `get_position()` over the students on that sheet (not `result_main`), so nobody is dropped; duplicate (student, subject, term) rows collapse to one (legacy `GROUP BY`, engine keeps the first).
- Classes for cumulative are listed from `result_main` rows of the **2nd** term only (same as legacy cascade), so a class with only 1st-term results doesn't appear under cumulative.
- **Legacy annual (S2) semantics**, all mirrored by `broadsheet_build_annual()`: subjects = every submitted subject of the class in the session (any term); student scores come from **all terms with no subclass filter** (unlike cumulative), so a student who moved subclass keeps every term; the chosen subclass only picks which 3rd-term `result_main` rows supply `cumulative_average` + `cumulative_position_subclass` (`cumulative_position_class` for All), and students without one are dropped; subject score = `round(sum / distinct terms, 2)` (NULL = 0, still counts as a term); Total = sum of displayed scores (not `cumulative_total`, which legacy selects but never shows). `annual_only_last_subjects = yes` keeps only the (student, subject) pairs present in the class's 3rd term (whole class, no subclass filter); a student with no 3rd-term subjects keeps a row with all dashes and Total 0 (legacy would `array_sum(null)` → TypeError on PHP 8).
- `broadsheet_term_ready()` is gone: every term key is buildable since S2, so the modal lists all legacy options and the "Coming soon" guard is removed.
- **Legacy annual breakdown (S3)** reads the same `subject_term_total` the compact average is built from, but only columns `1st Term` / `2nd Term` / `3rd Term`. A term cell is a dash when `isset` fails, so a NULL total is a dash and is **not** added to the subject Total (unlike the compact average, where NULL counts as 0 and still counts as a term). Subject Total is a dash when the student has no submitted row for that subject. Term names other than the three (a shadowed `Mock Term`) still feed the compact average and have no breakdown column. `breakdown.sum` is `null` for a dash and otherwise `round(sum of the non-null cells, 2)`.
- The app breakdown table is **not** a DataTable: DataTables rejects a two-row grouped header. Header clicks sort on `data-order` (numeric when both cells parse, else string); the search field filters both tables. Print appends `&view=breakdown` only while that view is showing, and the print page ignores the param unless the sheet is annual.
- **S4 preselect** is server-side inside `get_broadsheet_modal` (`bs_picker_prefill`). Posted `bs_*` values are selected only when they appear in the caller's lists; a miss stops there and leaves downstream selects empty instead of failing the modal. The shared picker script only forwards `data-bs-*` from the Change button.
- **Desktop subject scope (S5)** is `audience=desktop` only. A plain `academic` (form teacher or not) gets level `subject` from `staff_subject_subclass`. The app never sets that audience, so a non-form academic stays `none` and a form teacher stays `form`. Desktop `get_term` stays unscoped (legacy had no role filter). Classes join `staff_subject_subclass` on `class_id` only; subclasses join on `subclass_id` only. All Subclasses stays in the desktop subclass list, including for subject teachers. Print maps non-numeric `subclass` (`all_subclasses` decrypts to empty) to engine `'all'`, which a subject teacher may open for a class they teach. A subject teacher who opens a class they do not teach is now `forbidden` — legacy print did not check.
- **Print token vs phone browser session:** `browser:` opens the system browser, which does not have the app WebView session and often already has another login (parent/student/staff) on the same host. A valid `pt` token must authorize the print by itself. Preferring the browser session, or 403ing when it does not match the token, blocks administrators (reproduced: Universal Admin token + parent `PHPSESSID` → "You do not have access"; same URL with no cookie rendered the sheet).
- **Desktop class labels** come from `result_main.class_name`, which diverges from `class.class_name` on the demo DB (JSS1 vs JSS 1, SSS2 vs SS 2, and class 5 also has GRADE 5). `broadsheet_classes` on audience=desktop keeps the legacy `GROUP BY rm.class_id` select so the option HTML matches. The app still uses `class.class_name`.
- **Empty session/term (S5)** now returns the failure JSON (`No Broadsheet Available For This Session` / `…Term`) instead of a success dropdown with only the placeholder. That is the `$session_term` bugfix; sessions that have results are unchanged. Demo `result_main`/`result_subject` for 2015/2016 have `subclass_id` 0 only, so the live dropdown is All Subclasses.
- **Extra-result duplicate scores (E0):** demo student 32 has the same subject submitted in subclass 0 and subclass 1. Last fetched wins, and that order follows the desktop SELECT list (`ers.total, ers.total_percent`, `ORDER BY name`). A shorter SELECT list changes which duplicate wins. Do not DISTINCT them.
- **Extra-result class labels (E0):** app cascade uses `class.class_name` (JSS 2). Desktop `e_r_main.class_name` is JSS2 for class 2. Sheet title still uses `class.class_name`, so All Subclasses keeps the legacy double space (`BROADSHEET OF MID TERM RESULT JSS 1  FOR …`).
- **Extra-result subclass 0 (E0):** class 4 has only `subclass_id` 0. Full access gets All Subclasses and no Whole class. A form teacher of that pair gets id `0`, labelled Whole class. SUMMER LESSON categories have no `e_r_main` rows, so they never appear in the cascade.
- **Extra-result Convert (E1):** the mode only chooses `e_r_subject.total` or `total_percent` after the same SELECT, so duplicate last-wins order is unchanged. `total_type` is a raw query param, not encrypted. Obtainable is the un-COALESCEd subcat sum; no `e_r_main` row leaves it `0` (desktop initializes `$all_total = 0`); a NULL sum stays null and the app header omits the parentheses. The engine also returns `obtainable` so the percent sheet can label Convert without a second query. Token claim `m`; a token with no `m` still means `total_100`. A percent token on a raw URL is invalid, and the reverse. Average stays `e_r_main.average` even when it is not the mean of the visible cells (demo JSS 1 All, student 71: stored 68.52, mean of raw cells 61.67, raw total 185 vs percent total 205.55).
- **Extra-result Change (E2):** prefill is server-side in `get_extra_broadsheet_modal` (`erb_picker_prefill`). Posted `erb_*` are selected only when they are in the caller's lists; a miss stops the cascade and leaves downstream selects empty. The picker forwards `data-erb-*` only from `.erb-change`, so the Summary button stays a blank cascade. Toolbar order when the sheet has rows is Convert, then Change, then Print (Change immediately left of Print). A zero-row success is still Convert + Change — E1 keeps Convert. A bad link the caller may replace is Change alone. Forbidden and blocked hide Change (`$erb_can_pick`). Viewing a new sheet from the picker returns to percent; `total_type` is not a picker field.
- **Extra-result desktop (E3):** cascade option HTML matches the legacy queries (desktop class label stays `e_r_main.class_name`, so JSS2 not JSS 2). Empty session/term/category now returns the failure JSON instead of a placeholder-only success — the `$session_term` bug. Subclass stays a success response and still starts with unencrypted `all_subclasses`. Prepared FLOAT columns (`e_r_subject.total`) come back as doubles; `erb_mysql_text()` restores the `query()` string so the AJAX JSON stays quoted. App scope is unchanged (form stays `form`, non-form academic stays `none`, app class label stays `class.class_name`). Desktop academic, form teacher or not, stays `all`. A principal stays `none`. No Excel on the app — Print only. Desktop Excel and Convert markup were not touched.
- **Extra result (pre-E0, from the desktop sheet — do not "fix" these in the engine):** there is no cumulative or annual mode. `e_r_main.terms` is the term (`1st Term`, `2nd Term`, `3rd Term`). The cumulative parse in `print_blank.php?print=broadsheet-extra-result` is dead code; the query uses the two-word term only. Cascade is Session → Term → Category → Class → Subclass, from any `e_r_main` rows. Subjects are `e_r_subject` with `status='submit'`. A student with no `e_r_main` average for that class/subclass/term/category is dropped. Displayed Total is `round(array_sum(visible subject cells), 2)`, not `e_r_main.total` (the percent-of-`e_r_main.total` value is computed and never shown). Average is always `e_r_main.average`, including after Convert. Position is `position_class` for All Subclasses and `position_subclass` otherwise, always shown. No reg-no column. Header max is `100` in `total_100` and `subcat1_total+…+subcat10_total` in `total_normal` (a NULL subcat makes that sum NULL — do not COALESCE). Duplicate (student, subject) rows: the last fetched row wins. `get_term` / `get_class` guard on the global `$session_term`, same bug as class result; `get_category` correctly checks its own list. Desktop subclass dropdown always returns success and always starts with unencrypted `all_subclasses`, skipping empty subclass ids. Desktop class labels are `e_r_main.class_name`. Desktop `category` and `session` are read with `decrypt()`, `class` / `subclass` / `term` with `decrypt_from_url()`. `kick_out` is exact status `admin/academic_admin/backend/academic` — principals are out; director/backend pass because their status is `admin`.

---

## 8. Session log

| Date | Session | Slice | Notes |
|---|---|---|---|
| 2026-10-07 | Grilling | — | Decisions Q1–Q20 locked; doc created |
| 2026-10-07 | Build | S0 | New: `admin/includes/broadsheet_engine.php`, `app/admin/broadsheet.php`, `app/admin/broadsheet-print.php`, `app/admin/includes/broadsheet-app-print-token.php` (reuses the CBT token's base64url helpers). Edited: `summary.php` (button + `bs_*` modal JS), `zapapp.css` (Summary `--2` actions, blocked chip, `/* ── zap-app: broadsheet ── */`). Cumulative/Annual keys are filtered out of the modal via `broadsheet_term_ready()` — S1/S2 extend that function. **Verified:** engine vs legacy parity (10 combos, identical); scope (backend all, form teacher own pairs only, form "all" / other class forbidden, non-form academic forbidden, blocked); staging browser as demo Director: Summary button, modal cascade, page + DT Print button, sticky column, session-auth print page, tampered / "Coming soon" URLs; print tokens via curl (valid form-teacher token renders, token reused for another subclass → invalid, bad signature → expired message, token for someone else's class → no access). **Pending (device only):** Print opening the system browser via `browser:` in the WebView + auto-print; a real form-teacher and `block_check_result = yes` pass through the app UI; 2h expiry (mint enforces ≥ 300 s, logic shared with CBT) |
| 2026-10-07 | Build | S1 | Engine only: `broadsheet_build_cumulative()` (prepared statements, `get_prev_term()` + `get_position()` from `functions.php`), `broadsheet_build()` dispatches on mode, `broadsheet_term_ready()` now admits `cumulative` so the modal lists "1st & 2nd Term Cumulative"; Annual still returns "Coming soon". No app page / print changes needed (hero pill uses `term_label`, print uses `title`). **Verified:** parity vs verbatim legacy cumulative branch on the demo DB — all 6 available combos (2015/2016, JSS 1–SS 3, All Subclasses) plus the subclass-`0` filter path for each, cell-for-cell + title identical (845 single-term subject cells exercised the per-term averaging; demo has no NULL totals, so that edge is matched by code reading only); staging browser as demo Director: page hero/table and session-auth print page with legacy title |
| 2026-10-07 | Build | S3 | Engine fills `breakdown` (terms + per student/subject cells) from the annual branch only; termly and cumulative stay `null`. App: Pattern G strip (Compact / Term breakdown) swaps `#broadsheet-compact` and `#broadsheet-breakdown-card` (2-row grouped header, sticky name, own sort); Print appends `&view=breakdown`. Print page renders the grouped table with legacy dashes. CSS under `/* ── zap-app: broadsheet ── */`. **Verified:** parity vs a verbatim port of the legacy breakdown cells — 16 annual combos (2015/2016, subclass + All, `annual_only_last_subjects` off/on, 4080 cells) plus a session-only `TEMPORARY` shadow (2nd-term totals nulled, 3rd-term rows deleted, six rows renamed `Mock Term`, 2040 cells), all identical; termly build has no breakdown. Rendered `broadsheet.php` and `broadsheet-print.php` through their templates: compact, breakdown, and print cells match the engine; termly page has no toggle; no PHP warnings. **Pending (device only):** toggle and header-sort in the WebView. |
| 2026-10-07 | Build | S2 | Engine: `broadsheet_build_annual()` (prepared statements), `broadsheet_build()` dispatches `annual` with legacy title `ANNUAL BROADSHEET OF … FOR <sess> SESSION`; removed `broadsheet_term_ready()` and its filter in `broadsheet.php` `bs_get_term`, so "Annual" now shows in the modal. Hero pill already reads `term_label` ("Annual"); print page unchanged. **Verified:** parity vs verbatim legacy annual branch on the demo DB — all 4 class/subclass combos with 3rd-term results (2015/2016) plus All Subclasses, each with `annual_only_last_subjects` off and on (16 runs, 1200 cells + titles identical; 160 multi-term subject cells). Demo has no NULL totals or subjects missing from 3rd term, so a second run shadowed `result_subject` with a session-only `TEMPORARY` table (45 3rd-term rows deleted, 50 totals nulled) — still identical. Staging browser as demo Director: annual page (hero "Annual", averaged scores) and session-auth print page with legacy title |
| 2026-10-08 | Build | S4 | Change button (tonal, `fa-sliders-h`) on the broadsheet toolbar, beside Print when the sheet has rows, alone when it doesn't (including a bad link the caller is still allowed to replace). Picker open/cascade/submit moved to `scripts/zap-broadsheet-picker.js`; Summary includes it and no longer inlines those handlers. `get_broadsheet_modal` accepts optional `bs_*` and preselects whatever is in the caller's lists (`bs_picker_prefill`). **Verified (staging, demo Director):** Summary Broadsheet button still opens a blank cascade and reaches a sheet; Change sits tonal beside Print (50/50) and reopens the picker with session/term/class/subclass selected (1st Term All Subclasses, then Annual JSS 2); changing class or term still resets downstream; a garbage URL shows "Link incorrect." plus a solo Change that opens an unselected picker; Summary Check result modal still loads. |
| 2026-10-08 | Build | S5 | Desktop cascade (`get_term` / `get_class` / `get_subclass`) and `print_blank.php?print=broadsheet` now read the shared engine. New scope level `subject` is desktop-only (`broadsheet_ctx_build(..., 'desktop')`); app scope is unchanged. Print markup is untouched; `$names` / `$subject_total` / `$main_*` / `$title` / breakdown cells come from `broadsheet_legacy_sheet(broadsheet_build())`. Empty session/term returns the failure JSON. **Verified:** before/after capture for admin, principal, and academic on 1st Term, 1st & 2nd cumulative, and Annual (terms, classes, subclasses, compact + breakdown HTML) — `diff -rq` clean (30 files). App academic non-form stays `none`, form teacher stays `form`, app class label stays `class.class_name`. Academic opening another class is forbidden. |
| 2026-10-08 | Fix | print auth | Phone Chrome 403'd a valid administrator `pt` token ("You do not have access") because that browser was already logged in as someone else. `broadsheet-print.php` now treats a valid token as the credential and ignores the unrelated browser session. **Verified:** same admin token + parent session cookie renders the sheet; no cookie still renders; tampered `pt` still expires. |
| 2026-10-08 | Grilling | E0–E3 | Extra-result broadsheet locked (EQ1–EQ12, §9). Not built. Next session is E0. |
| 2026-10-08 | Build | E1 | Convert swaps subject cells (`total_percent` ↔ `total`) and the header max (`100` ↔ category obtainable). Total is the sum of the visible cells. Average and position stay stored. Tonal Convert in `#erb-actions` whenever the build succeeded (including zero rows); hidden on forbidden / bad link / blocked. Reloads via `loadPage()` with raw `total_type`. Label `Convert to {obtainable}` on percent, `Convert to 100` on raw. Print link and token claim `m` carry the mode. Print page still has no Convert button. **Verified:** engine vs verbatim desktop query — all 11 demo combos × both modes (22 runs, 1144 cells) identical; 72 sheets actually swap scores; 101 rows have a stored average that is not the mean of the visible cells. Staging browser as demo admin: percent headers `(100)`, Convert to 90; after Convert headers `(90)`, Isaac Nwokeukwu 57 / 63 / 65, total 185, average still 68.52, position still 4th, button Convert to 100, Print URL carries `total_type=total_normal` and `pt` with `m`. Token-only print renders the raw sheet, no Convert, auto-print. Same token with `total_100` is invalid. Junk `total_type` is "Link incorrect." and hides Convert. Non-form academic stays forbidden. **Pending (device only):** Print opening the system browser via `browser:` + auto-print. |
| 2026-10-08 | Build | E2 | Change (tonal, `fa-sliders-h`) in `#erb-actions`. Beside Print when the sheet has rows (Convert, Change, Print); alone on a bad link the caller may still replace. Hidden when scope is `none` or the sheet is blocked. `get_extra_broadsheet_modal` preselects posted `erb_*` only when they are in the caller's lists (`erb_picker_prefill`); a miss stops there. Picker forwards `data-erb-*` from `.erb-change` only. **Verified (staging, logged-in admin):** Summary Broadsheet still opens a blank cascade; JSS 1 All Subclasses sheet shows Convert to 90, Change, Print; Change reopens the picker with session, term, category, class, and All Subclasses selected; changing class to JSS 2 resets subclass and loads B/C/A; a bad category shows "Link incorrect." plus solo Change, with session and term selected and category/class/subclass empty; a garbage URL opens an unselected picker; raw sheet (`total_type=total_normal`) still preselects the same cascade, header `ACCT (90)`, Convert to 100. |
| 2026-10-08 | Build | E0 | Percent-only extra broadsheet. New: `admin/includes/extra_result_broadsheet_engine.php` (calls `broadsheet_scope` for the app; desktop `erb_scope` does not), `app/admin/extra-result-broadsheet.php`, `app/admin/extra-result-broadsheet-print.php`, `app/admin/includes/extra-result-broadsheet-print-token.php` (salt `zap-extra-broadsheet-print-v1`), `app/admin/scripts/zap-extra-broadsheet-picker.js`. Edited: `extra-result-summary-cat.php` (Summary-only tonal Broadsheet button; Generate stays `--solo`), `zapapp.css` (`.erb-page` shares the broadsheet table rules; `/* ── zap-app: extra broadsheet ── */` for the 5-field sheet and the blocked chip). `total_normal` is "Link incorrect." **Verified:** engine vs verbatim desktop query — all 11 demo combos (2016/2017, 1st Term, MID TERM RESULT, subclass + All + subclass 0), 572 cells + titles identical, including the duplicate-score student. Scope: backend/principal all, form teacher own pairs only (class 4 → Whole class, no All Subclasses), form "all" / other class forbidden, non-form academic forbidden, desktop academic all with `e_r_main.class_name`, desktop principal none, blocked. Staging browser as demo admin: Summary button beside Check extra result, 5-field cascade (JSS 2 not JSS2, All Subclasses), page hero + `(100)` headers + Print, session-auth and token-only print page (legacy title, rotated header, dash, no Convert), tampered `pt` expired, category mismatch invalid, no cookie sign-in required, `total_normal` link incorrect, Generate page unchanged, Check extra result modal still opens. **Pending (device only):** Print opening the system browser via `browser:` + auto-print; a real form-teacher and `block_check_result = yes` pass through the app UI. |
| 2026-10-08 | Build | E3 | Desktop cascade (`get_term` / `get_category` / `get_class` / `get_subclass`) and `print_blank.php?print=broadsheet-extra-result` plus `return_broadsheet_extra_result_table` now read the extra-result engine (`audience=desktop`). Markup stays: Excel stays on the desktop sheet, Convert stays, subject header keeps `<span class="subject_total">`, AJAX empty cell stays the legacy `<hr>`. No Excel added to `app/admin`. Empty session returns the failure JSON. **Verified:** legacy SQL vs engine for admin and academic — term/category/class/subclass option HTML, both modes of the AJAX JSON, title, Convert button, and spanned headers — 20 demo combos, 0 diffs. App form stays `form`, app academic stays `none`, app class label stays `JSS 2`, desktop option stays `JSS2`, principal build is forbidden. |

---

## 9. Extra-result broadsheet

Staff open it from **ER — Summary** (`app/admin/extra-result-summary-cat.php?page=summary`), not from Generate. It is a different sheet from §1: one category, one term, two score modes. No cumulative, no annual, no term breakdown, no remark results, no Excel on the app.

### 9.1 Goal

1. A **Broadsheet** button sits beside **Check extra result** on the Summary toolbar only.
2. It opens `#fullEmptyModal` with Session → Term → Category → Class → Subclass.
3. **View broadsheet** navigates to `app/admin/extra-result-broadsheet.php` — DataTable, sticky name column, one **Print** button.
4. Print opens `app/admin/extra-result-broadsheet-print.php` in the system browser via `browser:` and a signed `pt` token, then auto-prints.

Data comes from `admin/includes/extra_result_broadsheet_engine.php`. Desktop `admin/broadsheet-extra-result.php` and `admin/print_blank.php?print=broadsheet-extra-result` switch onto it in E3. Do not overload `broadsheet_engine.php` or `broadsheet.php`.

### 9.2 Locked decisions

| # | Decision |
|---|---|
| EQ1 | **App roles** = ER — Summary audience, same rules as class-result Q1. Call `broadsheet_scope()` — do not reimplement it. Full access: director, backend, admin, academic_admin, any status containing `principal`. Form teachers: own pairs from `form_staff_class_subclass` only. Non-form academics: `none`. Enforced on the button, every endpoint, the page, and print. **Desktop** (E3 only, `audience=desktop`) is not that scope: exact `kick_out` statuses `admin` / `academic_admin` / `backend` / `academic` get `all` (a plain academic sees every class, form or not). Principals stay `none` on desktop. Director/backend pass because their status is `admin`. Do not pass `audience=desktop` into `broadsheet_scope()` — that would apply the class-result `subject` level and hide classes |
| EQ2 | Button on `page=summary` only. Generate keeps `md-gen-toolbar__actions--solo` and the single Check extra result button. Summary drops `--solo` when the broadsheet button renders, so the existing generate-page 2-column grid applies. Broadsheet is tonal, `fa-table`, label "Broadsheet". Check extra result keeps its current primary style — do not add `--success` |
| EQ3 | E0 is `total_100` only (what desktop GET BROADSHEET opens). `total_normal` is E1. A crafted `total_type=total_normal` before E1 is "Link incorrect." |
| EQ4 | Position column always. Do not read `should_position_show_in_broadsheet`. No reg-no column, including school 29 |
| EQ5 | Kill switch is `block_check_result == 'yes'`, same as class result. Disabled button + `Blocked` chip. Page and print: "Broadsheet deactivated." |
| EQ6 | New engine, page, print page, token helper, and `scripts/zap-extra-broadsheet-picker.js`. Field prefix `erb_` so Check extra result handlers (`name_admin_sess`, `class_name_admin_sess`, …) and class-result `bs_` handlers never fire. E3 is required, not optional |
| EQ7 | Full-access roles get "All Subclasses" (`id = all`) plus each real subclass. Form teachers never get All Subclasses. A form teacher whose pair has `subclass_id` 0/NULL gets id `0`, labelled "Whole class", filtered with subclass positions. Desktop E3 does not offer Whole class; it keeps unencrypted `all_subclasses` and skips empty subclass ids |
| EQ8 | Every app dropdown is scope-filtered. A form teacher only sees a session, term, category, or class when their pair has `e_r_main` rows. No All categories. One category per sheet |
| EQ9 | Convert swaps subject cells (`total_percent` ↔ `total`) and the header max (`100` ↔ category obtainable). Total is the sum of the visible cells. Average stays `e_r_main.average`. Position stays the stored position. Do not recompute either |
| EQ10 | Convert is a tonal toolbar button on the app sheet, not on the print page. Label `Convert to {obtainable}` while on percent, `Convert to 100` while on raw. It reloads the page in the other mode. Print URL carries the mode. Print page has no Convert button and auto-prints. Default remains percent |
| EQ11 | Hero overline `Extra broadsheet`. Title `{category} — {class} {subclass}` or `{category} — {class} — All subclasses`. Pills: Session, Term, Subjects. Stat: student count. Do not repeat the class name elsewhere |
| EQ12 | Slice order E0 → E3 (§6). E3 depends on E1 so desktop Convert still works when the data moves |

### 9.3 Files

| File | New/Edit | Responsibility |
|---|---|---|
| `admin/includes/extra_result_broadsheet_engine.php` | new (E0) | Cascade + build. Requires `broadsheet_engine.php` for `broadsheet_scope`, `broadsheet_ctx_build`, `broadsheet_is_blocked` only. Never calls `broadsheet_build` |
| `app/admin/extra-result-broadsheet.php` | new (E0) | Modal + cascade endpoints and the page |
| `app/admin/extra-result-broadsheet-print.php` | new (E0) | Standalone print page |
| `app/admin/includes/extra-result-broadsheet-print-token.php` | new (E0) | Mint/parse `pt`. Salt `zap-extra-broadsheet-print-v1` (must not verify as a class-result token). Reuse the CBT base64url helpers |
| `app/admin/scripts/zap-extra-broadsheet-picker.js` | new (E0) | Modal open, cascade, submit. Included by the Summary page and the broadsheet page. Not by Generate |
| `app/admin/extra-result-summary-cat.php` | edit (E0) | Summary-only button. Include the picker script only when `$page == 'summary'` |
| `app/admin/styles/zapapp.css` | edit (E0) | Add `.erb-page` beside `.broadsheet-page` on the shared table rules (sticky name, compact cells, Font Awesome sort icons). New `/* ── zap-app: extra broadsheet ── */` only for the 5-field sheet, the blocked chip on `.ers-cat-page`, and (E1) the convert button. Do not restyle `.summary-page` |
| `admin/broadsheet-extra-result.php` | edit (E3) | Cascade endpoints call the engine with `audience=desktop` |
| `admin/print_blank.php` | edit (E3) | `broadsheet-extra-result` page branch and `return_broadsheet_extra_result_table` read the engine. Markup stays |

### 9.4 Engine API

Same conventions as §3.2: `(mysqli $db, array $ctx, ...)`, no `$_GET`/`$_POST`, no echo/exit, arrays only, prepared statements, int casts. Public functions return `code = blocked` when `block_check_result == 'yes'`, and `code = forbidden` when scope is `none` or the pair is outside scope. `encrypt()` is not trust.

```php
// App: broadsheet_ctx_build($table_name, $info, $user_id)  — audience stays 'app'
// Desktop (E3): broadsheet_ctx_build(..., 'desktop') then erb_scope(), which does NOT call broadsheet_scope()
erb_scope(mysqli $db, array $ctx): array
    // app: broadsheet_scope(). desktop: ['level' => 'all'|'none']  (no pairs, no 'subject', no 'form')
erb_sessions(mysqli $db, array $ctx): array                 // newest e_r_main_id first, check_session()
erb_terms(mysqli $db, array $ctx, $sess): array             // [term => term], check_term(), no cumulative keys
erb_categories(mysqli $db, array $ctx, $sess, $term): array // [cat_id => cat_name]
erb_classes(mysqli $db, array $ctx, $sess, $term, $cat_id): array   // [class_id => class_name], class_order
erb_subclasses(mysqli $db, array $ctx, $sess, $term, $cat_id, $class_id): array
    // app: [['id' => 'all'|int, 'name' => ...]]; 'all' only when level == 'all'; id 0 name 'Whole class'
    // desktop: always starts with ['id' => 'all', 'name' => 'All Subclasses']; skip empty subclass ids
erb_build(mysqli $db, array $ctx, $class_id, $subclass_id, $sess, $term, $cat_id, $total_type = 'total_100'): array
    // E0: total_type other than total_100 → bad_request
    // E1: total_100 | total_normal
    // ok => data: [
    //   'title',          // desktop string, for E3 parity. App hero does not use it
    //   'cat_name', 'class_name', 'subclass_name', 'sess', 'term', 'cat_id',
    //   'total_type', 'header_max',   // 100 or the category obtainable (null if the legacy sum is NULL)
    //   'subjects' => [subject_id => subject_short],  // sorted by subject_short
    //   'rows' => [[student_id, name, scores => [subject_id => float|null], total, average, position, position_sort]],
    //   'show_position' => true, 'show_reg_no' => false,
    // ]
```

**Build rules** (port `print_blank.php` extra-result branch, both the page query and `return_broadsheet_extra_result_table`):

- Subjects: submitted `e_r_subject` for that category, session, term, class. No subclass filter on the subject list. Sort by `subject_short`.
- Header max: `total_100` → `100`. `total_normal` → `subcat1_total+subcat2_total+…+subcat10_total` from one `e_r_main` row for that category/session/term/class (no subclass filter). Do not COALESCE.
- Subject cell: `total_percent` or `total`. Missing subject → `null` (app renders `–`; print renders a short rule; E3 AJAX adapter emits the legacy `<hr>`).
- Subclass `all` → no subclass filter, position from `position_class`. Any int, including `0` → `subclass_id = ?` and `position_subclass`.
- Drop a student with no `e_r_main` average for that class/subclass/term/category.
- Total = `round(array_sum of that student's visible cells, 2)`. Do not use `e_r_main.total`, and do not use `round(total / all_total * 100, 2)` — legacy computes that and never displays it.
- Average = `e_r_main.average` in both modes. Position = the stored label. `position_sort` is the numeric prefix, same as the class-result sheet.
- Duplicate (student, subject) rows: last fetched wins. Do not DISTINCT them away.
- `subclass_id = all` requires scope `all`. Form scope must contain the `(class_id, subclass_id)` pair. Desktop `all` may open any class.
- App class labels come from `class.class_name`. Desktop class labels stay `e_r_main.class_name` with the legacy `GROUP BY class_id` (E3 option HTML must match). Category and subclass names on desktop stay the legacy columns and order (`e_r_main_id`).
- Term is the stored `e_r_main.terms` string, validated with `check_term()`. Do not invent `2nd Term Only` / Cumulative / Annual keys.

**App URL rule** (same as class result): every encrypted value is encoded once more (`encodeURIComponent` / `rawurlencode`) and read with `decrypt()`. Params: `class`, `subclass`, `session`, `term`, `category`. E1 adds `total_type` (`total_100` or `total_normal`). E3 keeps the desktop's raw links and its split decode (`decrypt` for session and category, `decrypt_from_url` for class, subclass, term). Map a non-numeric desktop subclass (`all_subclasses` decrypts to empty) to engine `'all'`.

**Token** (E0 claims `t,u,c,s,sess,term,cat,d,e`; E1 adds `m` = total_type, missing `m` means `total_100`): TTL 2h, tables `staff, backend, admin, director`, same secret derivation as the class-result token with salt `zap-extra-broadsheet-print-v1`. A valid `pt` wins over whatever session the system browser already has. Re-check role and form scope against the DB on every print. Claims must match the request's class, subclass, session, term, and category, and (E1) the mode.

### 9.5 Tracer bullet (E0)

1. Engine: `erb_scope`, `erb_sessions`, `erb_terms`, `erb_categories`, `erb_classes`, `erb_subclasses`, `erb_build` for `total_100` only.
2. Token helper: `erb_print_token_mint/parse`, `erb_print_link($class_id, $subclass_id, $sess, $term, $cat_id)`.
3. `extra-result-broadsheet.php` — `require includes/authenticate.php`. AJAX: `get_extra_broadsheet_modal`, `erb_get_term`, `erb_get_category`, `erb_get_class`, `erb_get_subclass`. CSRF on every POST, including the modal. Page shell `md-page class-result-page generate-page erb-page`.
4. Summary button + picker script. Generate page unchanged.
5. Print page. Bootstrap like `broadsheet-print.php`: `init.php`, not `authenticate.php`. Token wins over the phone browser session.
6. CSS as in §9.3.

**Modal.** Same zap-app sheet as the class-result picker (`md-sl-form md-summary-check-sheet md-erb-sheet`, intro icon `fa-table`, title "Broadsheet", lead "Pick a session, term, category and class."). Not the old pill-tab Check extra result markup. Selects: `selectpicker md-check-pick-select`, `data-container="#fullEmptyModal"`.

| Field | `name` / id | Spinner |
|---|---|---|
| Session | `erb_session` / `summary_erb_session` | — (options server-rendered) |
| Term | `erb_term` / `summary_erb_term` | `erb_load_term` |
| Category | `erb_category` / `summary_erb_category` | `erb_load_category` |
| Class | `erb_class` / `summary_erb_class` | `erb_load_class` |
| Subclass | `erb_subclass` / `summary_erb_subclass` | `erb_load_subclass` |

Submit: `md-btn-contained md-ripple erb_get_broadsheet md-check-pick__submit` ("View broadsheet", `fa-eye`). Footer: tonal Close. Validate session → term → category → class → subclass, one `notify` each. On success: `loadPage(); window.location.href = 'extra-result-broadsheet.php?…'` with `encodeURIComponent` on the already-encrypted values.

Each cascade change resets downstream selects to the refresh option and refreshes the selectpicker. Empty list: `notify` "No broadsheet for this session" / "…for this term" / "No category available" / "No class available", and reset downstream. A subclass list for a class the caller may open is never empty (All Subclasses, their subclass, or Whole class).

**Page.** Toolbar: Back (`extra-result-summary-cat.php?page=summary`, `loadPage()`) + Reload. `#erb-actions` receives the DataTables Print button. No Change button until E2. No Convert button until E1.

Before `header.php`: `$zap_load_datatables = true`, then `/admin/assets/plugins/datatables/dataTables.buttons.min.js` with `get_v()`. No `buttons.html5`, `jszip`, `pdfmake`, `buttons.print`.

Context hero from `partials/zap-context-hero.php` per EQ11. Search `md-search-field md-search-field--result` → `table.search(this.value).draw()`. DataTable: `dom: 'Brt'`, `paging: false`, `order: [[0, 'asc']]`, `autoWidth: false`, all columns sortable. Missing score: `–` with `data-order="-1"`. Position cell: `data-order="<position_sort>"`. Subject header text is `subject_short (100)` — the max is part of the header, unlike the class-result sheet.

Print button: `{ text: '<i class="fa fa-print"></i> Print', className: 'md-btn-contained md-btn-contained--success md-ripple', action: () => window.open(PRINT_URL, '_self') }`, then `table.buttons().container().appendTo('#erb-actions')`. `PRINT_URL` is `erb_print_link(...)`.

Measure the toolbar and set `--md-class-result-toolbar-offset` on `.erb-page`, same snippet as Summary. Stagger `--md-delay` 0 hero, 1 search, 2 card.

**Empty / error.** Same sentences as §4.4, with these extras:

| Situation | Behaviour |
|---|---|
| No category / no class | `notify` "No category available" / "No class available" |
| Build OK, zero rows | "No submitted results for this class and term." Print not rendered |
| `total_type=total_normal` before E1 | "Link incorrect." |
| Print token category or class does not match the URL | "This print link is invalid." |
| Phone browser logged in as someone else | A valid `pt` still prints |

**Print page.** Plain white page, school name + desktop `title`, rotated headers (port `th.rotate` from `print_blank.php`), empty cell = short rule, `@page { size: landscape; margin: 10mm; }`. `rs-bar` Print button hidden in `@media print`. `setTimeout(window.print, 300)`. `Cache-Control: no-store`. No Convert button.

**Manual test**

| Role | Expect |
|---|---|
| admin / backend / director / principal | All classes; All Subclasses present; header max is 100; Print opens the system browser and prints |
| Form teacher | Only own class + subclass; no All Subclasses; a class with no subclass offers Whole class; another class in the URL → "You do not have access…" |
| Non-form academic | No button; direct hit → forbidden |
| `block_check_result = yes` | Button disabled + chip; direct URL → "Broadsheet deactivated." |
| Generate page | Check extra result only. No Broadsheet button |
| Check extra result modal | Still opens. Its selects are not reset by `erb_` handlers |
| Tampered / expired `pt` | Invalid / expired message. A valid token still prints if that browser is logged in as someone else |

**Parity:** for 3 category/class/term combos, app cells (name, each subject, total, average, position) match desktop `print_blank.php?print=broadsheet-extra-result&total_type=total_100` for the same params. Header max is 100. Cell-for-cell.

**Exit:** matrix passes; parity matches; no SQL interpolation in the engine; Generate page unchanged; class-result broadsheet unchanged.

### 9.6 Later slices

**E1 — Convert.** Engine accepts `total_normal`. App toolbar button per EQ10, in `#erb-actions`, shown whenever the build succeeded (including zero rows), hidden on forbidden / bad link / blocked. Reload via `loadPage()` and `total_type` in the query. Print link and token claim `m` carry the mode. Print page still has no Convert button; it prints the mode it was given. Average and position unchanged (EQ9). Parity: the same three combos in `total_normal` match desktop after Convert, including an average that does not match the converted scores.

**E2 — Change.** Tonal `fa-sliders-h` in the toolbar, beside Print when the sheet has rows, alone when it does not (including a bad link the caller may still replace). Not shown when scope is `none` or the sheet is blocked. Posts current `erb_*` with `get_extra_broadsheet_modal`; server preselects only values that are in the caller's lists (`erb_picker_prefill`). A miss stops there and leaves downstream selects empty. Picker script forwards `data-erb-*` from the Change button only.

**E3 — Desktop, required.** `get_term` / `get_category` / `get_class` / `get_subclass` call the engine with `audience=desktop`. Empty session, term, or category returns the failure JSON (`No Broadsheet Available For This Session` / the term and category equivalents) instead of a success dropdown with only the placeholder — the `$session_term` bug. Subclass stays a success response and still starts with `all_subclasses`. Option HTML for classes and subclasses matches today's markup (legacy label source and order). `print_blank.php` page branch and the AJAX table keep their markup byte-identical: Excel stays, Convert stays, subject header keeps `<span class="subject_total">`, AJAX empty cell stays the legacy `<hr>`. `$names` / `$subject_total` / `$main_average` / `$main_position_subclass` / `$title` / `$class_subjects` come from `erb_build()`. A desktop academic who is not a form teacher can still open every class. A principal still cannot. App scope is unchanged.

**Parity for E3:** capture desktop cascade JSON and both compact modes of the HTML table + AJAX JSON before/after, for admin and academic. `diff` clean. App form teacher stays `form`. App class label stays `class.class_name`.
