---
title: Make the League Dashboard a Public Homepage
type: task
status: completed
description: >-
  Rework /leagues/:lid into a designed homepage for managers and the public: header first, a manager
  action section, league odds and the existing schedule list surfaced on the page, and a logged-out
  link to /genesis-league. No DDL, no new selectors.
base_uri: user:task/league/make-league-dashboard-public-homepage.md
created_at: '2026-09-04T20:14:44.602Z'
entity_id: 9b7c8e94-610a-4e93-ac2e-be160a4b83c7
finished_at: '2026-09-04T21:57:58.841Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
public_read: true
relations:
  - relates [[██████████████████████████-███████-████-███-████████]]
  - relates [[█████████████████████-██████-███-███-████████-█████]]
  - relates [[user:task/league/extract-shared-standings-row-component.md]]
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-11-06T00:03:44.579Z'
visibility_analyzed_at: '2026-11-06T00:17:57.225Z'
---

# Make the League Dashboard a Public Homepage

## Overview

Rework `/leagues/:lid` so it reads as a designed homepage for both audiences: the managers who run the league and the public arriving from search or from the platform landing page.

What concretely changes when this is done:

- A visitor with no session gets a page ordered by what they need — league identity first, then odds and dates, then the charts — instead of two unlabelled charts and a transactions list.
- The league's competitive picture becomes visible on the dashboard — playoff, bye and championship odds for every team, from data the page already loads.
- The league's governing dates move out of a collapsed sidebar accordion onto the page, including the free-agency auction close and the trade deadline.
- Managers stop seeing their action items scattered above and below the league name; they group into one section that disappears cleanly when empty.
- A logged-out visitor gets one call to action into the Genesis League pitch page at `/genesis-league`.
- Nobody reads second-person manager copy about a roster they do not have.

Ordering: this follows the platform landing page work, which shipped `/genesis-league` in `fe11d8af4` and links here as its own secondary call to action. It precedes [[██████████████████████████-███████-████-███-████████]], which was split out of this plan's design phase and whose surface is not yet decided. It also overlaps [[█████████████████████-██████-███-███-████████-█████]], which covers both files this task edits.

## Background

The task's original premise was wrong in a way worth recording, because it changes what the work is.

**The page is not a rich manager page that degrades for the public.** Rendered anonymously at `https://xo.football/leagues/1` in a clean browser profile (`localStorage.token` confirmed null), the page body holds exactly three populated grid items: the league header with format chips and the team-values-over-time chart, the positional-value chart, and recent transactions. It is thin for every viewer, not just for outsiders.

**Logged-in and logged-out views coincide for two separate reasons.** All three surviving widgets are league-scoped rather than team-scoped, and `DashboardLeaguePositionalValue` never reads its team prop — `league-home.js:337` passes `tid={teamId}`, react-redux's default merge does hand it to the component, and nothing in `dashboard-league-positional-value.js` reads it, so the chart is identical for every viewer. Separately, the RFA sections are outside their window: `restricted_free_agency_period_end` is 2026-08-11, so `is_before_restricted_free_agency_end` suppresses the nomination card and the schedule for members too, and no player currently carries an RFA tag. Waiver claims, poaching claims and cutlist are empty unless a manager has something pending at that moment.

**The league is NOT quiescent, and an earlier draft of this plan said it was.** `free_agency_period_start` is 2026-09-03 and `free_agency_period_end` is 2026-09-08, so the free-agency auction window is **open right now**. What is suppressed is the free-agency *notice*, which `league-home.js:110-128` renders only BEFORE the period starts, the `isBefore` check being at `:112`. The most time-critical date for both audiences is the auction close, and no surface on THIS PAGE shows it — the sidebar accordion does.

**Odds are already rendered elsewhere in the app, and the data aggregation already exists.** `GET /leagues/1/teams`, which this page already calls, returns per-team `playoff_odds`, `championship_odds`, `bye_odds` and `division_odds`, and the client `Team` record declares all four (`app/core/teams/team.js:20-23`), so nothing is dropped. Measured live: playoff, championship and bye odds are non-null for all ten teams (Chiffonnier Plantocracy 0.9992 and 0.4553; New Team Name 0.1564 and 0.0028); `division_odds` is null for all ten, structurally rather than transiently, because the league runs `has_division_winner_berths: false`. The standings page renders these at `app/views/pages/standings/standings.js:41-48` alongside records, and four `dashboard-team-summary-*-odds` components render league-wide odds tables in accordions. **The gap is that the dashboard shows none of it, not that the app lacks it.**

**Records are deliberately NOT included, and the reasoning changed twice.** `get_overall_standings` (`app/core/selectors.js:3137`) joins records to odds and `GET /leagues/:lid/team-stats` needs no auth, so reuse looked obvious. Three facts defeat it. The selector is a plain function, not memoized, so using it as a reselect input returns a fresh reference on every dispatch and would defeat memoization of this page's much heavier connector, including `calculatePercentiles`. It keys on `app.year` via `get_teams_for_current_league_and_year`, while this page is year-agnostic, so navigating here after using the standings year selector would silently show a past season. And measured live today, **every record field is zero for all ten teams** — the season has not started, so a records block is ten identical `0-0-0` rows. The odds already sit in the store, year-agnostic and memoized. Odds render here; records stay one click away on the standings page.

**The deadline aggregation also already exists, and it is complete.** `get_league_events` (`app/core/selectors.js:749-923`) walks the league timestamps, skips nulls, drops past events and returns `{ detail, date }`; `LeagueScheduleList` renders them. It emits fifteen events, including `Free Agency Period Begins` and `Ends`, `Trade Deadline`, `Waivers Processed`, `NFL Opening Day` and `Offseason Begins`. Its consumers are two sidebar connectors (`league-schedule-list/index.js`, `league-schedule/index.js`) — **not** `/leagues/:lid/schedule`, which renders matchups by week from a different page entirely.

So the auction close IS already rendered today, inside a collapsed sidebar accordion. **The gap is placement, not data:** the dates that govern the league sit in chrome a visitor must know to open, rather than on the page. This plan surfaces the existing component and adds nothing to the selector.

**Everything this page needs is anonymously readable.** Nothing 401s: `api/routes/leagues/index.mjs:404`, `teams.mjs:315`, `rosters.mjs:231`, `players.mjs:125`, `transactions.mjs:167`. `page-routes.mjs:323-327` gives the route indexable robots and describes it as "team values, upcoming deadlines, and recent transactions" — values and transactions render, deadlines never have. The route's `title` stays "Front Office": this task changes what the page shows, not its metadata.

**Presentation problems, observed from the rendered page:** no section carries a heading or a gloss, so a visitor meets a "Team Market Value" chart with no explanation; the league name renders twice, once in the sidebar and once as the page `h1` directly below it; the two charts are each roughly a screen tall, pushing transactions far below the fold; and the "copy markdown url" control floats above the page title with no visual relationship to anything.

**No description column, and none needed.** `db/schema.postgres.sql:5377` confirms `name varchar(50)` is the only human-readable field on `leagues`. Rather than adding one, the logged-out call to action points at `/genesis-league`, which carries the full pitch. **This plan ships no DDL**, so the one-commit schema rule does not apply to it.

### Assumptions

- `/genesis-league` exists and is anonymously reachable — checked: `app/views/routes.js:50` and `:208`, `libs-shared/page-routes.mjs:68`, commit `fe11d8af4`.
- `LeagueHeader` has exactly one consumer, so restructuring it reaches no other page — checked: grep across `app/` returns only `league-home.js:11` and `:319`.
- Logged-in state is `Boolean(app.userId)`, computed per connector with no shared selector, so an inline computation matches convention rather than skipping an abstraction — checked: `grep -rl "is_logged_in" app/views --include=index.js | wc -l` returns 11 connector files, including `menu/index.js:19`.
- Nothing here forces an `@mui/*` import: the standings page builds its table from plain `div.table__row` and `div.table__cell` (`standings.js:23-31`), and the landing page links with `NavLink` (`landing.js:3`). The ratchet (`test/app.mui-import-ratchet.spec.mjs`) pins `@mui/material: 204` by exact equality in BOTH directions, so **a removal reddens master exactly as an addition does** — a restructure that drops the last `Stack`, `Chip`, `Alert` or `Grid` import must lower the budget in the same commit.
- Odds reach the client intact: `app/core/teams/team.js:20-23` declares all four odds fields, so the Immutable record drops none of them — checked, not assumed, since a field the API returns and the record omits is silently discarded.
- Records are publicly readable but currently all zero: `api/routes/leagues/team-stats.mjs` has no auth check and returns 200 anonymously with ten rows — checked by reading the route and by curling it — and every record field is 0 because the season has not started. This is why records are left to the standings page.
- `division_odds` is null for all ten teams because the league runs `has_division_winner_berths: false` — checked live. It is a format property, not a transient gap, so the column is simply not rendered for this format.
- `get_league_events` omits free agency and the trade deadline — checked by reading the selector body at `app/core/selectors.js:749` and enumerating the events it pushes.
- `LeagueScheduleList`'s connector merges `getTeamEvents` with `get_league_events`, and `getTeamEvents` is team-scoped (rookie pick, poaches against my players), so it contributes nothing for an anonymous visitor and adds a manager's own dates for a member — checked at `league-schedule-list/index.js` and `selectors.js:3196`.
- **Unchecked:** whether every league in every season has a non-null value for each date. The existing selector already skips nulls, so reuse inherits the correct behaviour rather than needing new handling.
- `season_started_at`, `season_finalized_at` and `archived_at` are on the API payload but NOT declared on the client `League` record, so they are unavailable client-side without a three-place record change. This plan does not use them and does not add them.

Two design decisions, recorded here rather than left implicit — they are choices, not claims that could be false:

- The page `h1` stays despite duplicating the sidebar's league name. It is what makes the page read as a homepage and carries the SEO weight; the sidebar is chrome, and `app/views/components/menu/` is outside this task's scope.
- The odds section uses its own compact markup rather than extracting a shared component from `standings.js`. Extraction is the cleaner end state, but it would edit the standings page and create a component directory while [[█████████████████████-██████-███-███-████████-█████]] is in progress across those same files. The markup divergence is recorded as a follow-up rather than pretended away; the underlying odds come from one place, the teams already in the store.

## Design

One page, four sections, ordered by who needs what. **The design principle is reuse:** every content addition below consumes an existing selector or component, because the app already computes all of it.

**Identity, for everyone, first.** `LeagueHeader` moves to the top of the grid: league name as page title, format chips, and the values-over-time chart under a heading with a one-line gloss on what team market value means. The chips currently render only when `!is_in_league`, which makes a manager's identity section thinner than a stranger's — the wrong way round for a page serving both. Chips render for everyone.

**Manager Actions, only when non-empty.** The notices, waiver claims, poaching claims, cutlist and RFA nomination card group under one heading instead of scattering above and below the header. Second-person copy gates on membership so no stranger reads it. `RestrictedFreeAgencySchedule` does NOT move here — it is league-wide public content today (`league-home.js:258-260`) and stays in the League section.

**League, for everyone.** Something scannable before the charts:

- **Odds** — teams sorted by championship odds, read from the teams already in the store, with no new fetch and no new selector. Renders `playoff_odds`, `bye_odds` and `championship_odds`; not `division_odds`, which this league's format never populates. Ports the postseason gate from `standings.js:196-199`, without which stale playoff odds render all through the postseason — and ports it to the header cells as well as the body cells, since the gate appears in both and porting one misaligns the columns. Links through to the standings page, which is where records live. Carries a gloss saying the odds are simulation projections, since the season has not started.
- **Dates** — the existing connected `LeagueScheduleList`, one import and one grid item, with no selector change. Its content is complete today; this moves it out of a collapsed sidebar accordion onto the page.
- Then the two charts, each with a heading and a gloss, then recent transactions.

**Call to Action, logged out only.** A single `NavLink` to `/genesis-league`.

No in-page navigation section. The sidebar menu already links every league page plus Login, and duplicating chrome is not worth the page's best real estate. Dates are not exempt from that rule by accident: the sidebar's schedule accordion is collapsed chrome, and the dates here are page content — but the same component renders both, so there is one implementation.

Every section renders its heading only when it has content. An empty section under a heading is a labelled void, which is worse than absence.

### File organization

Most work stays within this task's fence — `app/views/pages/league-home/` and `app/views/components/league-header/` — and no new component directories are created.

**No fence expansion.** An earlier revision proposed extending `get_league_events`, on the belief that it omitted free agency and the trade deadline. It does not — the enumeration behind that claim stopped a third of the way through the selector. Everything this plan needs from `app/core/selectors.js` is already there, and the file stays untouched.

CSS classes follow the page's existing BEM form (`.league__home` in `league-home.styl:1`), not the flat form also present on the page. Headings reuse the existing `.section` and `.heading__section-title` vocabulary rather than inventing a parallel class, and this page keeps its own visual language rather than building on `app/styles/prose-form.styl`, which a sibling session is actively rewriting uncommitted.

### Knowledge Base Impact

- No new domain entry document; this task body carries the plan.
- No ABOUT.md edits, no new tags.
- One new relation: `relates` to [[██████████████████████████-███████-████-███-████████]], created during this task's design phase to carry the content-feed integration.
- Two observations to persist at close: the corrected premise, and the dead `tid` prop.
- One reference-doc reconciliation to check rather than assume: whether anything under `docs/` describes this page.
- **No scratch slug.** The work produces no multi-file artifacts outside the repo.
- **No orchestrator.** Bounded to one page and one component within a single task entity.

### Gloss copy

Written at plan time because "a one-line gloss" has no done-condition until the sentence exists. Each is checked against what the code actually computes, not against the label above the chart.

- **Odds** — "Simulated from the current rosters and the remaining schedule. The season has not started, so these are projections rather than standings; full records are on the standings page."
- **Team market value** — "Each line follows one team's roster value in league dollars over time. The figure beside each team name is its value today." Checked: the chart plots `league_team_daily_values` with a `Team Market Value` y-axis, and the legend formatter appends the latest value in dollars (`league-teams-value-over-time.js:118-137`).
- **Projected Points+** — "Each bar totals the points a roster is projected to add above a replacement-level starter, split by position, with draft picks valued alongside." Checked: the selector sums `pts_added` per position floored at zero and adds a `DRAFT` series from `get_draft_pick_values` (`app/core/selectors.js:2121-2129`).
- **Recent transactions** — "The league's latest signings, releases, and claims."

## Notes

- Verify both a full page load and an in-app navigation to `/leagues/:lid`; they produce different app state.
- The styling oracle is the emitted `dist/*.css`, never the source — an undefined stylus variable emits a bare ident and a missing mixin emits nothing.
- Icons come from `@components/icon`, never `@mui/icons-material`.
- Use `yarn dev:live` for visual verification, on a free port — sibling dev servers usually hold 8091 and 8095.
- Put verification scripts and screenshots under `scratch/`, not the league checkout's `tmp/`, which the fleet wipes mid-session.

## Plan

### Layout and Sections

- [x] **Header becomes the page title**
  - File: `app/views/components/league-header/league-header.js` and `league-header.styl`
  - Purpose: the league's name is the first thing on the page rather than section nine.
  - Changes: promote the name and chips into a page-title block; show the format chips to everyone rather than only to non-members; give the values-over-time chart a heading and a one-line gloss on team market value. Dropping the `!is_in_league` gate at `league-header.js:61` orphans the `is_in_league` prop — remove its whole plumbing in the same step: the destructure at `:11`, the `propTypes` entry at `:79`, and the connector value at `league-header/index.js:14`.
  - Verify: load `/leagues/1` anonymously AND with a member token; the `h1` is the first content element in `.page__body` and the chips render in both cases. `grep -rn "is_in_league" app/views/components/league-header/` returns nothing, and `yarn lint` plus `test/app.connected-component-prop-contract.spec.mjs` stay green.

- [x] **Reorder the grid into the four sections**
  - File: `app/views/pages/league-home/league-home.js`
  - Purpose: the current order is implementation accident; this is the order the design approves.
  - Changes: Identity first, then Manager Actions, then League, then the logged-out Call to Action.
  - Verify: with no session, the rendered sections appear in the designed order — assert on the DOM order of the section headings, not on the source.

- [x] **Group Manager Actions and gate second-person copy on membership**
  - File: `app/views/pages/league-home/league-home.js`
  - Purpose: a stranger should never read "You will not be able to release any players" (`league-home.js:118-119`).
  - Changes: wrap the notices, waiver, poach, cutlist and RFA-nomination sections in one section under a heading; render the heading only when the section has content; gate second-person notice copy on `is_team_manager`. Leave `RestrictedFreeAgencySchedule` in the League section — it is public content.
  - Verify: anonymously, no second-person copy appears in the page text and the section heading is absent; with a member token holding a pending claim, the section renders; the RFA schedule remains visible anonymously during an RFA window.

- [x] **Add the logged-out call to action**
  - File: `app/views/pages/league-home/league-home.js`, `app/views/pages/league-home/index.js`
  - Purpose: give a logged-out visitor the route to the full league pitch.
  - Changes: add `is_logged_in` as `Boolean(app.userId)` to the connector; render a single `NavLink` to `/genesis-league` when logged out.
  - Verify: the link is present with no session and absent with a minted session token; `/genesis-league` returns the pitch page rather than a 404.

### Content

- [x] **Odds section, from the teams already in the store**
  - File: `app/views/pages/league-home/league-home.js`
  - Purpose: the league's competitive picture is the first thing a visitor wants, and the data is already loaded with no fetch to add.
  - Changes: render the ten teams sorted descending by `championship_odds`, showing `playoff_odds`, `bye_odds` and `championship_odds`, in plain `div.table__row` / `div.table__cell` markup as `standings.js:23-31` uses; omit `division_odds`; port the postseason gate from `standings.js:196-199` to BOTH the body cells and the header cells; link to the standings page for records. No connector change and no new dispatch — the page already loads teams at `league-home.js:67`.
  - Verify: rendered odds match `GET /leagues/1/teams` for all ten teams; with the clock set past the regular season, playoff and bye columns disappear from header AND body together while championship remains; the section renders anonymously.

- [x] **Dates section, reusing LeagueScheduleList**
  - File: `app/views/pages/league-home/league-home.js`
  - Purpose: the league's governing dates are rendered today only inside a collapsed sidebar accordion, so a visitor has to know to open chrome to find them.
  - Changes: render the existing connected `LeagueScheduleList` as a grid item under a heading. No new computation, no connector change, no selector change — it already skips nulls and past events and carries the shared label vocabulary.
  - Verify: pre-registered against league 1 as of 2026-09-04 — the rendered list is identical to the sidebar accordion's, and contains `Free Agency Period Ends` at 2026-09-08T02:00Z and `Trade Deadline` at 2026-12-13T04:59:59Z. Both label strings must match `app/core/selectors.js` verbatim rather than being reworded here.

- [x] **Headings and glosses on every section**
  - File: `app/views/pages/league-home/league-home.js`, `league-home.styl`
  - Purpose: an unlabelled chart is meaningless to a visitor who does not already know the league.
  - Changes: heading plus one-sentence gloss for the standing block, the values chart, the positional-value chart and the transactions list, reusing the existing `.section` / `.heading__section-title` vocabulary rather than new classes; the standing gloss must say the odds are simulation projections; render each heading only when its section has content; re-home the floating "copy markdown url" control, and correct the now-stale comment at `copy-markdown-button.styl:1` which says the control is for the league home page.
  - Verify: every rendered section has a heading; a league with no transactions renders neither the heading nor an empty container; no control floats above the page title; the four gloss sentences are written into this plan before implementation starts.

### Cleanup

- [x] **Remove the dead team prop and the dead groups computation**
  - File: `app/views/pages/league-home/league-home.js`
  - Purpose: both mislead the next reader of the file this task is restructuring. `tid={teamId}` at `league-home.js:337` reaches the component and is never read, which is part of why the manager and public views are identical. `groups` at `league-home.js:130-139` is computed on every render and never read at all.
  - Changes: delete the prop from the call site and delete the `groups` computation. Both are entirely within this file — `dashboard-league-positional-value.js:236-240` declares only `teams`, `summary` and `league_positions`, so there is no `tid` propTypes entry to chase. Deleting `groups` orphans the `fantasy_positions` import at `league-home.js:20`, whose only use is that block; remove it in the same step.
  - Verify: the chart renders identically before and after, compared against the current page; `grep -n "groups\|fantasy_positions" app/views/pages/league-home/league-home.js` returns nothing, and `yarn lint` is clean — a grep alone passes while lint fails on the orphaned import.

### Testing and Validation

- [x] **Anonymous render check**
  - Purpose: the audience this task exists for.
  - Verify: load `/leagues/1` in a clean browser profile with `localStorage.token` null, and confirm section order, the standing block, the dates list, the call to action, and the absence of manager copy. Screenshot to `scratch/`.

- [x] **Member render check**
  - Purpose: the manager path must not regress, and it is the half the anonymous check cannot see.
  - Verify: mint a token with `scripts/generate-jwt-token.mjs`, seed it into localStorage as a JSON string, and confirm the Manager Actions section renders and the call to action does not. Delete the token file afterwards.

- [x] **Both navigation paths**
  - Purpose: a full page load and an in-app navigation produce different app state.
  - Verify: reach `/leagues/1` by direct load and by clicking through from another league page; the rendered sections match.

- [x] **Gates stay green**
  - Purpose: three gates fire on exactly the shapes this plan produces, and a red master defers every session's push.
  - Verify: `yarn test:isolated test/app.mui-import-ratchet.spec.mjs test/app.connected-component-prop-contract.spec.mjs test/app.connected-component-props.spec.mjs` — the prop-contract gate fires on both removing `tid` and adding a connector value, and the ratchet fails on a REMOVED `@mui/*` import as well as an added one, so if the restructure drops the last `Stack`, `Chip`, `Alert` or `Grid` the budget moves down in the same commit. Re-pull before running, since the Material UI removal task is in progress across these same files. Then `yarn lint`, `yarn check:types`, and `npx prettier --check` on the touched files.

- [x] **Degenerate-league render check**
  - Purpose: every new section must degrade to absence rather than to a labelled void, and the odds path has a whole-table null case the live league does not exhibit.
  - Verify: render against a league with no `████████████████████` rows (every odds column null) and confirm the standing block drops rather than showing names with no columns; confirm a league with no transactions renders no transactions heading. Use a local API against a seeded test database rather than production.

### Knowledge Base Updates

- [x] **Record the durable findings as observations**
  - Entity: this task.
  - Changes: the corrected premise (three league-wide widgets for every viewer, not a manager page that degrades); the dead `tid` prop and dead `groups` computation with their lines; and the review lesson worth carrying — that two content items were planned as new work because absence was concluded from a search of `libs-shared/` and of this page alone, when both already existed in `app/core/selectors.js` and on the standings page.
  - Verify: `base entity get` shows the observations.

- [x] **File the shared-standing-markup follow-up**
  - Purpose: this plan renders its own compact standing markup because extracting a shared component would edit the standings page while the Material UI removal task is in flight. That is a timing decision, not an end state, and it should not evaporate.
  - Changes: create a task for extracting the standings row markup into a shared component consumed by both surfaces, and relate it to this one.
  - Verify: the new task exists and `base relation forward` on this task resolves it.

- [x] **Reconcile stale reference docs**
  - Changes: check whether anything under `docs/` describes the league home page, and update what this work makes stale.
  - Verify: grep `docs/` for references to the Front Office page and to the sections this plan reorders; state the result either way.
