---
title: Surface the utilization components on the league client's player-facing surfaces
type: task
status: completed
description: >-
  Make player_expected_fantasy_points_share_from_plays and its population percentile readable on the
  fantasy team dashboard, the players table and data view, and the players-page gamelogs and
  seasonlogs. The data-view columns are already declared and render; the other surfaces cannot reach
  the composable share at all.
base_uri: user:task/league/surface-utilization-components-on-player-facing-surfaces.md
created_at: '2026-09-24T15:29:12.109Z'
entity_id: e4c195b3-8d11-4935-8094-4d82d33c140e
finished_at: '2026-09-24T16:58:19.319Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[██████████████████████-███████████-████████]]
  - relates [[user:task/league/add-population-percentile-to-data-view-measures.md]]
  - relates [[████████████████████████-████████-█████-██-██████-██████-███████████]]
started_at: '2026-09-24T15:36:20.733Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-24T17:09:15.309Z'
visibility_analyzed_at: '2026-11-06T00:18:07.788Z'
---

## Problem

The utilization-components chain ([[██████████████████████-███████████-████████]] and [[user:task/league/add-population-percentile-to-data-view-measures.md]], both Completed 2026-09-24) shipped `player_expected_fantasy_points_share_from_plays` and its population percentile as data-view measures. Both are declared and selectable on the data-view surface; neither reaches the league client's other player-facing or consumer surfaces. This task makes that data readable where a user actually looks.

## Scope

Three surfaces, per the handoff brief:

- **Fantasy team dashboard** — surface the team's utilization/expected-share reading per scoring format/player.
- **Players table and the data view** — confirm the data-view columns render and sort (the PCTL column was verified ascending/descending on dev:live 8091 during the chain); wire where the players table and the data view overlap.
- **Players page gamelog and seasonlogs** — currently cannot reach the fitted family at all (same blocker the expected-stats surfacing hit: they render from `player_gamelogs`/`player_seasonlogs` plus three advanced side tables per grain, none of which carries a fitted column).

## Context the successor needs

- A sibling lane (continuation `█████████████████████████████████████████`, thread live, task [[████████████████████████-████████-█████-██-██████-██████-███████████]]) owns the **expected-stats** family surfacing — fitted per-stat `xYDS`/`xTD`/`xFP`/`xPOE` columns on the same Games/Seasons tabs and dashboard. Do not redo or collide with that lane: the two families are distinct data (this is the composable share-of-expected-fantasy-points + percentile, that is the fitted per-stat model). The gamelog/seasonlog wiring pattern that lane establishes is the template to reuse.
- The data-view columns are already declared and rendered (PCTL column, 0-100, sorts both directions). The other two surfaces cannot reach the data today.
- Always verify against dev:live (not `yarn dev`), per [[███████████████████████████████-█████████]].
- The benchmark caveat governs any publish of the score: no predictiveness claim without an interval excluding zero (Reproduction section of [[████████████████████████-████-███████████-████████]]).

## What was measured, 2026-09-24

### The data view is done, and carried one live defect

Both columns are declared on both sides with a title, a header label and a column group — `player_expected_fantasy_points_share_from_plays` as `xFP%` and `player_expected_fantasy_points_share_percentile_from_plays` as `xFP% PCTL`, both in `COLUMN_GROUPS.OPPORTUNITY`. Server definitions, `RATIO_COLUMN_IDS`, the client field registry and the golden are all in place, and the field-parity and capability-parity specs cover them.

**The percentile column's user-facing description named the SUPERSEDED floors** — RB 3.0 / WR 1.5 / TE 2.0, the opportunity-share calibration — against the shipped RB 3.5 / WR 3.0 / TE 3.1. The recalibration moved the qualifier and left the sentence behind. Fixed at `f01b922ca` by deleting the restatement rather than correcting it: the floors moved to `libs-shared/qualified-role-share-floors.mjs` and the description renders them, so the two cannot drift again.

**The first sweep for other copies missed one, and the miss is the reusable lesson.** That sweep ran a pattern matching the DESCRIPTION's phrasing (`3.0% for RB`), validated it against the known match on `HEAD`, and returned nothing else — a validated zero over the wrong pattern. `libs-server/data-views/measure/percentile.mjs` carried the same superseded floors in its own header, spelled `RB 3.0%, WR 1.5%, TE 2.0%`, and was found only by reading the file for another reason. Validating a pattern proves it can match the instance you already knew about; it says nothing about the instances spelled differently. Both are now pointers to the shared module rather than restatements.

### The players page is a SEPARATE surface from the data view, and the share cannot reach it

The two are not one surface with an overlap. `app/views/pages/players/` renders fixed views from `app/core/players/default-players-views.js` over `app/core/player-fields.js`, fed by `libs-shared/calculate-stats-from-plays.mjs` — a JS aggregation over fetched plays, a different pipeline from the data-view SQL builder entirely.

**`stats._wopr` is in the receiving view** (`default-players-views.js:195`), which is the metric this chain documented as superseded, so this is the surface where the gap is most visible.

It is also the surface the share cannot be added to cheaply, and the reason is the one `format-expected-points-from-plays.mjs` already ruled on: the value is scored by the requesting league's SCORING FORMAT, and putting it in `calculate-stats-from-plays.mjs` would re-implement the lateral guard, the excluding-touchdown first-down substitution, the QB-kneel exclusion, the position-varying reception value and the bonus exclusion in JS, with nothing reporting the drift. This page is superseded by the data view and is not worth a second scoring implementation.

### Gamelogs, seasonlogs and the dashboard need storage, at FORMAT grain

Same structural blocker the expected-stats lane hit, with the same resolution available. `scoring_format_player_gamelogs` and `scoring_format_player_seasonlogs` already carry `expected_points_from_plays` and `points_over_expected_from_plays` from that lane, and the share belongs beside them rather than on the rushing/receiving side tables, because it is format-scoped for exactly the same reason.

The share is NOT derivable from what is stored. The numerator excludes the passing role, which `expected_points_from_plays` includes, and the denominator is a TEAM-GAME quantity that exists nowhere at rest.

**Attribution must follow the cohort expansion, not the team.** The data-view denominator sums the team totals of the games the player was an ACTIVE roster member for (`player_gamelogs.is_active`, joined on `possession_nfl_team`), so a mid-season trade gives a season denominator spanning two teams. A writer that summed one team's whole season would disagree with the rendered column for every traded player.

The stored writer does NOT need the 49x cohort expansion: team totals per `(esbid, possession_nfl_team)` joined to the active gamelog rows produce the same denominator from an unexpanded scan.

**The play filter is `stat_countable_play_types`**, inherited from the from-plays factory — NOT the non-nullified filter the fantasy-points columns use. Each stored column must agree with the data-view column that is its oracle.

### The percentile-band shading machinery is empty here, so it cannot carry the percentile

`PercentileMetric` shades against `percentiles` table bands. Measured on production: the format-scoped keys `INDIVIDUAL_GAMELOG_<POS>_<format_id>` that `scripts/generate-nfl-player-gamelog-percentiles.mjs` computes have **zero rows** — only the six unsuffixed position keys exist. Every format-scoped cell on the Games tab is unshaded today, `points` and the expected-stats lane's `xFP`/`FPOE` included. Band shading is therefore not a channel this task can surface a percentile through.

## Plan

- [x] Inventory the three surfaces and state, per surface, exactly which utilization/percentile values are reachable and which are not. — DONE 2026-09-24, above.
### Store the share at both format grains, and its percentile at season grain

DDL applied 2026-09-24 from a private ad hoc DDL file: `expected_points_share_from_plays` on `scoring_format_player_gamelogs` and `_seasonlogs`, plus `expected_points_share_percentile_from_plays` at season grain only.

`libs-server/format-role-share-from-plays.mjs` is the writer, and it RENDERS the atom rather than restating it — `REALIZED_OPPORTUNITY_ATOM` emits the player half and the team half from one fragment under two predicates, and the combine is `guarded_divide`/`apply_decimals`. The percentile is the shipped column's own `render_percentile` over the shipped declaration. So the stored value and the rendered value are the same arithmetic over the same SQL rather than two implementations that agree today.

**Measured against the oracle, not argued.** Rendered the two data-view columns for 2024 REG under one format and compared the top 2,000 rows: 2,000 compared, **0 share differences and 0 percentile differences**. The control fires hard and by the whole population — swapping the cohort join's `is_active` for `is_starter` takes the same comparison to 1,248 matched rows with both columns differing, so the zero is evidence rather than a check that cannot report.

**The percentile is SEASON GRAIN ONLY, and the writer refuses a game-grain one** rather than obliging. Its floors were calibrated on the season distribution; the qualified count at week grain has never been measured, and this family already paid twice for floors transferred without re-measuring.

Both generators are wired, so the weekly chain carries it with no further work, and `scripts/backfill-format-role-share.mjs` fills the history. That script never touches `points` — the same fence its sibling carries, because re-running the format-log history recomputes `points` from drifted gamelogs and flipped a completed 2020 head-to-head on 2026-09-14.

- [x] Surface the data on the fantasy team dashboard. — DONE 2026-09-24. Its own **Utilization** group (Share, Pctl) beside Expected Points, threaded through `get-players.mjs`, `league-team/`, `player-roster/` and `player-roster-total/`. The roster TOTAL row holds both as dashes: each player's share is against HIS OWN team's denominator, so summing a multi-team roster adds fractions of different wholes and is not a share of anything, and a percentile does not sum at all.
- [x] Surface the data on the players page gamelogs and seasonlogs. — DONE 2026-09-24. `xFP%` joins the Games tab lead group and the Seasons tab fantasy group, where `PCTL` sits beside it as a qualifier cell. The Games tab gets the value only, per the season-grain ruling above.
- [x] Confirm the data view / players-table overlap renders and sorts correctly. — DONE 2026-09-24. There is no overlap to wire: the two are separate surfaces over separate pipelines, and the finding is written up above. The data view's own defect (the superseded floors in the percentile description) is fixed at `f01b922ca`.
- [x] Verify end-to-end on dev:live and note any test-gap. — DONE; **deployed 2026-09-24**, see the deploy section below.

## Deployed 2026-09-24

Operator-approved, shipped from a throwaway worktree because the shared tree carried five other lanes' uncommitted files. Both hosts at `6680b943f`, `DEPLOY_ACK_RANGE=26`, zero refusals, chain reached its final step.

Verified on the hosts rather than on the exit code, each positive paired with a control that reads zero:

- The API carries the columns — `api/routes/players.mjs`, `get-players.mjs` and `format-role-share-from-plays.mjs` all match on the deployed host; an invented column name matches nothing.
- `server` restarted 99s before the reading, so the process runs the new code rather than merely holding the new file.
- The served bundle `main.d5f87eb5.js` is the one just built and EXISTS ON DISK. That is the discriminating check: Cloudflare serves a deleted asset from the edge, so reading the page is not evidence.
- The deployed `dist/` carries both columns across three chunks; the invented name matches none.

**A deploy opened a gap that the deploy itself could not see, and this is the part worth keeping.** Master moved WHILE `deploy:all` ran. The hosts `git pull` origin at reload time, so they landed on `6680b943f` while the bundle had been built from `8099b2572` — the API ended up three commits AHEAD of the SPA, one of them (`ef242ff6f`) a frontend change to the very selected-row components this task's columns render in. Nothing in the deploy output says so, and every host-sha check passes, because both hosts agree with each other and with origin. The tell is only visible by comparing the host sha against the sha the BUNDLE was built from, which is not one of the checks the guide's list spells out.

Closed by rebuilding at `6680b943f` and re-running `deploy:dist` with `deploy:sourcemaps` paired, so the maps match the new chunk hashes.

**Do not read this as "re-pin and retry" being the general fix.** The range is a moving target by construction: the same race can recur on any deploy that takes longer than the gap between two sibling pushes. What generalizes is the READING — after any deploy, compare the host sha against the built sha, not just the host sha against origin.

### What the dev:live check can and cannot reach

**`dev:live` points the SPA at the DEPLOYED API**, not a local one — `NC_DEV_LIVE=true` sets `IS_DEV` false and `app/core/constants.js:17` resolves `BASE_URL` to `https://xo.football`. The deployed API runs the pre-`1b5fd3977` select lists, so the new cells render empty on dev:live however the client is built. The columns and the backfilled data are live in production; only the API that would serve them is not.

So the three new surfaces are **verified as far as the client**, and end-to-end verification is gated on a deploy, which is operator-owned. Nothing about the remaining risk is in the data: the stored values already match the rendered column exactly.

**What was verified.** A dev:live build off this commit compiles with zero errors and carries all four surfaces. The corrected floors description reaches the client (`3.5% for RB` present, `3.0% for RB` gone), `expected_points_share_from_plays` and its percentile reach both the dashboard chunk and the player-profile chunk, `xFP%` reaches the player-profile and data-views chunks, and `Utilization` reaches the dashboard chunk.

**A false negative nearly shipped out of this check, and it is the reusable part.** The first probe grepped `main.js` alone and reported every new string absent — including `Pts+ Earned`, which has been in `fantasy-stats.js` since before this lane. The build is CODE-SPLIT: those components live in lazy chunks, so absence from `main.js` is the expected reading for working code and proves nothing. A known-shipped control string is what exposed it. Probe every emitted asset, and carry a control that must be present.

### Suite

**12,180 passing, 1 failing**, and the failure is not this lane's: `roster.salary-in-force-single-implementation` threw ENOENT on `libs-server/prop-market-grain-shape.mjs`, which the prop-market lane retired in `8c47b69b4` while this run was globbing `libs-server`. It passes on re-run at HEAD. A shared-tree race between a glob and a concurrent delete, not a defect in either lane.

Note the exit code was **0 with a test failing**, so the summary line is the oracle here rather than the status — the repo's own rule, met in practice.

### Test coverage

`test/libs-server.format-role-share-from-plays.spec.mjs` executes the real SQL against seeded plays and a seeded cohort. It pins the two properties no reader can check by looking — the active cohort's shares SUM TO 100, and the passing role is in neither half — plus the cohort join's `is_active` predicate, the position source the percentile partitions on, the season-grain refusal, and the explicit-null merge.

Both controls fire on distinct cases and neither is vacuous: relaxing the cohort join's `is_active` fails it on the inactive player getting a row, and crediting `passer_pid` in the numerator alone fails it on the passer reading 63.28 instead of 0.
