---
title: Import matchups for external league mirrors
type: task
status: completed
description: >-
  Add a matchup-sync module so an imported mirror has matchups, schedule and standings; four nav
  surfaces are gated off today because nothing fills them.
base_uri: user:task/league/import-matchups-for-external-league-mirrors.md
created_at: '2026-09-21T02:11:56.975Z'
entity_id: 95378cb9-e11e-4520-a7b4-e95f60407ced
finished_at: '2026-09-27T20:56:26.931Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - succeeds [[██████████████████████-████████-██████-██████-█████████████]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-27T20:56:26.931Z'
visibility_analyzed_at: '2026-11-05T23:49:37.873Z'
---

## Problem

An imported mirror has no matchups, because nothing imports them. `libs-server/external-fantasy-leagues/sync/` carries `config-sync`, `team-sync`, `roster-sync` and `transaction-sync`, and no matchup equivalent. League 121 and league 122 both hold zero `matchups` rows.

Four navigation surfaces are gated off as a direct consequence, and the gates are correct today:

- **Matchups** and **Schedule** render blank.
- **Standings** and **Stats** render every team at `0-0-0`, `0.0` points and `0.0%` playoff odds — an authoritative-looking page that is entirely empty, which is worse than an absent one.

The gating lives in `app/views/components/menu/menu.js` and is commented with this reason. Landing this task is what makes removing those gates correct.

## Plan

- [x] Add `get_matchups` season coverage where it is missing, and make the nine non-target adapters refuse explicitly rather than returning `[]`. — **AUDITED 2026-09-24, and the standard already holds everywhere. No code change was needed.** All eleven adapters plus the base class were read, rather than the five the original item named: ESPN (`adapters/espn.mjs:919`) and Sleeper (`adapters/sleeper.mjs:944`) are implemented; `yahoo:135`, `fleaflicker:133` and `mfl:134` throw directly; `cbs:73`, `fantrax:73`, `ffpc:73`, `nffc:73`, `nfl:73` and `rtsports:77` route through `stub_get_method`; `base-adapter.mjs:130` throws for anything unregistered.
  - The load-bearing fact is that `stub_get_method` (`adapters/stub-adapter-helpers.mjs`) **throws** — it logs and then raises `create_not_implemented_error`. A reader who assumed a "stub" returns an empty value would have recorded this item as open. Nothing in the adapter set returns `[]` for matchups.
- [x] Write `libs-server/external-fantasy-leagues/sync/matchup-sync.mjs` beside the existing four, keyed through `resolve_season_context` from `sync-utils.mjs` and never `current_season.year`. — **LANDED in `89027156e` 2026-09-24.**
- [x] Invoke it from `sync-orchestrator` after `team-sync`, which it depends on for `team_mappings`, and independently of `roster-sync`. — **LANDED in the same commit.** Placed BEFORE roster sync rather than after: a roster sync walks every week and is the longest step, so ordering the schedule behind it would mean a run that times out mid-roster leaves the league with no matchups at all.
  - **Two adapter defects were fixed on the way, both latent only because nothing called `get_matchups` yet.** ESPN's took no `year` and read `current_season.year` out of the module, making it the one read method on that adapter that could not answer for a prior season — the exact trap this task warns about twice. And Sleeper has no home/away at all, so the adapter now orders the pair by roster id; that is correctness rather than tidiness, because `matchups` is uniquely indexed on `(away_team_id, home_team_id, season_year, week)` and an orientation that flipped between runs would miss its own row on upsert and store one real game twice, each copy holding the other's points.
  - The canonical matchup shape is now `home_*`/`away_*` rather than `team1`/`team2`, so each adapter owns the assignment. Nothing consumed the old shape.
  - **One guard was written backwards first and the suite caught it.** Raising "team mappings missing" in matchup-sync fails the whole sync for a valid EMPTY league, because `team-sync` already throws when teams exist and none resolve — so the only way to reach matchup-sync with an empty map is a vendor that served no teams, which correctly has no games. The wrong version reddened four cases in `fetch-and-sync-verdict.spec.mjs`, which is what surfaced it.
- [x] Run `scripts/process-matchups.mjs` for the mirror so `league_team_seasonlogs` is computed from the imported rows, per the standings ruling below. — **DONE in `9e5e46309`.** The script has no `is_hosted` gate of its own; two silent defects stood in front of it instead, both fixed there: `getRoster`'s INNER salary join emptied a mirror's roster, and `process-matchups` overwrote the vendor's imported matchup points.
- [x] Add the vendor-record reconciliation control — **DONE in the same commit**, and compared at the **TEAM-WEEK** grain rather than on a season total, because a season total lets a game credited to the wrong week cancel out, which is the mis-keying the control exists to find. It reports and never reconciles. Mirror 121 returns 0 of 24 divergent; 123 and 122 diverge on every played team-week.
- [x] Widen the `is_hosted` gate in `calculate-league-careerlogs.mjs:329` once something writes `league_team_seasonlogs` for a mirror. — **DONE.** `scripts/calculate-league-careerlogs.mjs` now iterates `get_derived_data_league_ids()` (hosted leagues and mirrors alike) and skips a designed refusal per league by name rather than failing the run.
- [x] Remove the four `is_hosted` gates in `app/views/components/menu/menu.js` together with the comment explaining them, driven against a mirror and a hosted league as a pair. — **DONE in `4ee54db63`, deployed (production `1047853ae`).** Matchups, Schedule, Standings and Stats now gate on data presence (`has_matchups`, `has_team_seasonlogs` from `libs-server/league-data-presence.mjs`) rather than `is_hosted`; the hosted-only group keeps `is_hosted` with its reason in the comment.
- [x] Playoff odds — **DONE via [[█████████████████████-███████-██████████-██████████-███-████████-██████████]] (Completed 2026-09-25).** **RULED 2026-09-24: a mirror DOES get playoff odds.** Not suppressed, not a hosted-only column. See the Open design questions entry for what that costs, which is more than a gate flip: the simulation has to become composable and extensible enough to run an external league's own playoff and championship format.

## Closure state (2026-09-27)

Production: mirror 121 holds 206 matchups (110 played) and 12 `league_team_seasonlogs` rows. Mirrors 122 and 123 hold matchups (70 and 108) but zero seasonlogs, because the derived-data pipeline refuses both on their IDP starter slot. The data-presence menu gate hides their Standings and Stats honestly instead of showing an all-zero page. That residual is owned by [[████████████████████████-██████████-█████████-██████████]] (roster half, continuation `████████████████████████`) and, for 122's playoff format, [[███████████████████████-█-███████-██████-███-████-██████████]] — not by this task.

## Why it is not a gate flip

**Re-measured 2026-09-23, discharging the `[stale-premise]` observation — the original reading is superseded but the conclusion survives.** The 2026-09-21 reading (one distinct week, 3 slot types on league 121) predated `c05ab7e88`, which made mirror roster sync a weekly series, and `08770a88c`, which resolved real starting slots. Current state of `rosters_players`:

| lid | platform | season | weeks | slots present |
| --- | --- | --- | --- | --- |
| 121 | sleeper | 2026 | 1, 2, 3 | 1,2,3,4,7,8,11(,12,13 wk3) |
| 123 | sleeper | 2026 | 1, 2, 3 | 1,2,3,4,7,8,11(,12,13 wk3) |
| 122 | espn | 2026 | 1, 2, 3 | 11(,13) — starters still pending re-sync |
| 121 | sleeper | 2025 | 0 | 11,12,13 (legacy pre-series snapshot) |

So a mirror now holds a genuine per-week roster with genuine starters, not a snapshot. **The conclusion is unchanged and the reason is different.** A weekly roster of starters gives you each team's weekly POINTS once scoring runs; it does not give you who played WHOM. The pairing is the vendor's schedule and exists nowhere in imported data, so matchups still have to be fetched. Do not read the richer roster as evidence that this became derivable.

## Scope

- A `matchup-sync` module beside the existing four, invoked by `sync-orchestrator` after `team-sync` (it needs `team_mappings`) and independent of `roster-sync`.
- Adapter support for reading a season's matchups. Sleeper and ESPN first; the other nine adapters may refuse explicitly rather than silently returning `[]`, which would read as a league with no games.
- Writing `matchups` rows keyed to the mirror's `lid` and the season the mirror actually plays, NOT `current_season.year` — see the `resolve_season_context` doctrine in `sync-utils.mjs`, which two earlier bugs in this pipeline came from.
- Deciding whether `league_team_seasonlogs` should be populated from imported matchups. `calculate-league-careerlogs` is gated on `is_hosted` and reads that table; widening that gate is a no-op until something writes it for a mirror, so this is the task that unblocks it.

## Open design questions

- **Does a mirror's standings get computed, or imported?** The vendor already knows each team's record. Computing it from imported matchups risks disagreeing with what the user sees on ESPN; importing it means standings and matchups can contradict each other. Pick one and say why.

  **Recommendation, researched 2026-09-23: COMPUTE, and spend the vendor's record as a control rather than as the stored value.** The question reads as a coin flip and is not one, because standings are not a table — they are a projection of `league_team_seasonlogs`, which `scripts/process-matchups.mjs` writes from matchup rows and which the SPA reads through `get_overall_standings` (`app/core/selectors.js:3347`).

  That table carries **51 columns**, and the vendor supplies four. ESPN's `record.overall` (`adapters/espn.mjs:1275-1279`) yields `wins`, `losses`, `ties`, `pointsFor` and `pointsAgainst`; Sleeper's roster settings yield the same shape. Everything else is derived from matchups crossed with starter slots — `all_play_wins/losses/ties`, `potential_points`, `potential_points_penalty`, `potential_wins/losses`, `potential_points_percentage`, `highest_weekly_score`, `lowest_weekly_score`, `weekly_score_deviation`, `weekly_high_scores`, `starter_slot_1_points` through `starter_slot_18_points`, the six positional `starter_points_*` columns, `division_finish`, `regular_season_finish`, `post_season_finish`, `overall_finish` and `draft_order_index`.

  So importing the record does not avoid the failure this task exists to fix — it reproduces it one layer in. A Standings page backed by four imported columns and forty-seven zeros is the same authoritative-looking empty page described in Problem, made worse because the four honest columns lend credibility to the rest.

  Computing has a second argument that is not about coverage: it needs **no new concept**. Import matchup rows keyed to the mirror's `lid`, then let `process-matchups.mjs` run — the identical path a hosted league takes. Nothing new branches on `is_hosted`, no parallel standings writer exists to keep in step, and Stats and Schedule come along because they read the same table. An imported-record path would be a second source of truth for a number the first source already produces.

  **The disagreement risk is real, and an oracle handles it better than avoidance does.** The vendor's record arrives on the same call as the matchups, so after `process-matchups` runs, compare it against the computed `regular_season_wins/losses/ties` and `points_for` and report a mismatch rather than storing the vendor value over the top. A divergence means the imported matchup set is incomplete or mis-keyed — exactly the defect class this pipeline keeps producing via `resolve_season_context` and the `current_season.year` trap. Note the control is strictly stronger than importing: importing makes the two agree by construction and so can never detect that bug.

  One trap to carry into implementation, from [[███████████████████████████████-█████████████████]]: `home_points` is zero on an unplayed week and is written once at settlement, never live. Assert coverage at the WEEK grain and for `> 0`, or an unplayed-week zero reads as a successful import of an empty game.
- ~~**Playoff odds.**~~ **RULED 2026-09-24 by the operator: a mirror gets playoff odds.** The suppression option is off the table, so the "plausible wrong number" risk is answered by making the simulation CORRECT for a mirror rather than by hiding the column.

  **That is the whole cost of this ruling, and it is a real piece of engineering rather than a gate flip: the simulation system has to become composable and extensible enough to run the playoff and championship format of the external league.** A mirror does not play this platform's postseason. Playoff team count, bye count, the number of wildcard rounds, which weeks are championship weeks, division structure, seeding rule and tiebreakers are all the vendor's, they differ per league, and Sleeper and ESPN each express them in their own payload. A simulation that assumes the hosted format and is pointed at a mirror produces exactly the authoritative-looking wrong number this task exists to eliminate — worse than the all-zero page, because it is not obviously empty.

  So this splits into three pieces of work, and the last two may deserve their own task:

  - The playoff format has to become **data** — read from the league or season row — rather than logic baked into the simulator.
  - The adapters have to **read the vendor's postseason settings** and map them onto that representation. Both Sleeper and ESPN carry it in the league settings payload the config sync already fetches.
  - The simulator has to **consume the format** instead of assuming one, with the hosted format expressed in the same representation rather than kept as a special case — otherwise the hosted path is a second implementation to drift.

  Scoping this against the current simulator is under investigation 2026-09-24; until it is measured, do not assume the format is already parameterized.
- **Which seasons.** A mirror can hold several seasons via the chain walk. Matchup import should follow the same season set rather than inventing its own.

## Done when

- A mirror shows real matchups, schedule and standings.
- The four `is_hosted` gates in `menu.js` are removed, with the comment that explains them.
- The removal is driven against a mirror and a hosted league as a pair, and the two readings differ.
