---
title: Remove an external league from your account on three surfaces
type: task
status: completed
description: >-
  Make the existing leave-mirror verb findable as one 'Remove from my account' action on the league
  home page, the import page and account settings, backed by a new external_leagues field on
  /api/me.
base_uri: user:task/league/remove-external-league-from-account-on-three-surfaces.md
created_at: '2026-09-29T03:55:54.841Z'
entity_id: 5775ce8a-0e37-42af-9071-6aeb467a6ce4
finished_at: '2026-09-29T14:21:05.330Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - follows [[████████████████████████-████████-██████-██████-███-████████]]
  - relates [[█████████████████████-████-████████-███████-███-█████████]]
started_at: '2026-09-29T03:56:01.763Z'
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-29T14:21:05.330Z'
visibility_analyzed_at: '2026-11-05T23:50:00.709Z'
---

# Implementation Plan: Remove an external league from your account on three surfaces

## Overview

- Make removing an external mirror league from your account findable in three places: the league home page, the import page, and account settings.
- **Impact:** a signed-in user who has imported or claimed an external league can find and use one labelled action, "Remove from my account". Today that verb exists only as "Leave league", folded behind "Manage" on the league home page.
- **Ordering:** this follows [[████████████████████████-████████-██████-██████-███-████████]], which shipped the server leave verb and its confirm copy, and [[█████████████████████-████-████████-███████-███-█████████]], which shipped the import picker. Nothing is blocked on it.

## Background

- **Leave verb:** `DELETE /leagues/:lid/external/claims` at `api/routes/leagues/external.mjs:996`. It releases every claim the caller holds on a mirror, in every season, refuses hosted leagues with 403, and returns 404 when nothing is claimed.
- **League home page:** "Leave league" lives inside the collapsed claim-team "Manage" line (`app/views/components/claim-team/claim-team.js:221`). The confirm modal is inline at `:245`.
- **Confirm copy:** `describe_leave_league_confirmation` in `app/core/external-leagues/claim-team-offer.mjs:356`. The body carries the operator's 2026-09-22 ruling that leaving can make a mirror reapable.
- **Reaper:** runs daily at 05:20 ET (`████████████████████████████████████████████████████████████████████`). It measures age from the connection's `created_at`, so an old mirror goes on the first run after its last claimant leaves.
- **`/api/me`** (`api/routes/me.mjs:185`) returns only current-season claims (`:199-209`). No endpoint lists a user's mirrors across seasons.
- **Import picker:** `with_imported_state` (`api/routes/external-leagues.mjs:352`) attaches `imported: {league_id, is_hosted}` and does not say whether the caller holds a claim.
- **Refresh after release:** `watch_release_fulfilled` (`app/core/external-leagues/sagas.js:271`) re-reads `/api/me` after leave, release and unlink.
- **Stale `teamId`:** `AUTH_FULFILLED` (`app/core/app/reducer.js:150-156`) sets `teamId` only when a team is found in the current league, and otherwise leaves the old value.

### Assumptions

- **Stale `teamId` survives a leave on the page being viewed.** UNCHECKED — inferred from reducer structure; the reducer spec step observes it before the fix.
- **`users_teams` is the whole representation of a claim.** Checked by the predecessor task (`me.mjs` reads it; no other claim table).

## Design

- **Server, `/api/me` gains `external_leagues`.** It lists every mirror (`is_hosted` false) where the caller holds a `users_teams` row in any season: `league_id`, `name`, `platform`, and `teams` as `{team_id, season_year, name}`. It uses the leave route's own claim join, so a listed league always has something to release. It sits on `/api/me` rather than a new endpoint, so the existing post-release re-read refreshes it with no new wiring. The query lives in a new helper, `libs-server/get-user-external-leagues.mjs`.
- **No change** to the leave endpoint, the reaper, or the connection-delete refusal of mirrors.
- **Client, one shared component** at `app/views/components/remove-external-league/`: a "Remove from my account" button, the confirm modal, and the dispatch of the existing `leave_external_league` action. The claim-team panel's inline modal moves into it.
- **Confirm copy:** the body stays verbatim. The title becomes "Remove {league} from your account" and the confirm label becomes "Remove from my account".
- **League home page:** the collapsed claim-team line shows the team, "Manage" and "Remove from my account". "Release this team" stays inside Manage. "Leave league" is deleted from Manage (operator ruling 2026-09-29).
- **Settings:** a "Linked leagues" section after "Connected platforms", one row per league with a name link, platform, team and remove button. The section is not rendered when there are no linked leagues.
- **Import page:** an imported mirror row whose league id is in `external_leagues` gets the remove button beside "Already imported — open it". The row updates from the `/api/me` re-read, so no picker re-fetch is spent. The import confirm's "That link cannot be removed yet" is stale since 2026-09-23 and becomes "You can remove it later from your account settings".
- **App state:** the app reducer stores `external_leagues` from `/api/me`, read through a selector.
- **Stale `teamId` fix:** `AUTH_FULFILLED` sets `teamId` to null when no team is found in the current league. Confirm first that `GET_TEAMS_FULFILLED` never relied on the stale value.
- **Knowledge Base Impact:**
  - `text/league/external-league-data-system.md` gains the three surfaces and the `external_leagues` field.
  - No ABOUT.md, tag, or guideline change.
- **Working tier:** no scratch slug is needed.
- **Orchestrator shape:** bounded, so a single task is enough.

## Notes

- Implement in one league worktree and merge locally per [[user:workflow/software/merge-worktree.md]]. Deploy through the league deploy lane, never between :25 and :40 past the hour.

## Plan

### Server

- [x] **Add `get_user_external_leagues`**
  - File: `repository/active/league/libs-server/get-user-external-leagues.mjs`.
  - Changes: query the mirrors where the user holds a claim in any season, grouped per league.
  - Verify: a spec covers a past-season-only claim (listed), a hosted league (excluded), another user's claim (excluded), and a two-season claim (listed once with two teams).
- [x] **Attach `external_leagues` to `/api/me`**
  - File: `repository/active/league/api/routes/me.mjs`, plus the swagger doc.
  - Verify: an API spec asserts the field on the `/api/me` response.

### Client

- [x] **Store `external_leagues` and fix the stale `teamId`**
  - Files: `app/core/app/reducer.js`, `app/core/selectors.js`.
  - Verify: a reducer spec shows `teamId` null after an `AUTH_FULFILLED` with no team in the current league, confirmed red before the fix.
- [x] **Change the confirm copy**
  - File: `app/core/external-leagues/claim-team-offer.mjs`.
  - Verify: the leaf spec asserts the new title and label, and that the body is unchanged.
- [x] **Add the shared component**
  - Path: `app/views/components/remove-external-league/`.
  - Verify: lint, and a browser check.
- [x] **League home page**
  - File: `app/views/components/claim-team/claim-team.js`: remove action on the collapsed line, "Leave league" and the inline modal deleted.
  - Verify: a browser check on a mirror.
- [x] **Settings: Linked leagues**
  - File: `app/views/pages/user-settings/`.
  - Verify: a browser check with and without linked leagues.
- [x] **Import page**
  - Files: `app/views/pages/import-league/` and `app/core/external-leagues/import-steps.mjs`: remove action on claimed mirror rows, and corrected import-confirm copy.
  - Verify: a browser check.

### Validation

- [x] **Full suite and lint in the worktree**
  - Verify: exit 0 with the summary line present.
- [ ] **Merge, deploy, and check production**
  - Verify: host shas match, the served bundle is current, and a test-user remove succeeds from each surface.

### Knowledge Base Updates

- [x] **Update `text/league/external-league-data-system.md`**
  - Verify: grep for "Leave league" returns no stale wording.
