
Fpl Copilot
- 246 installs
- 130 repo stars
- Updated June 19, 2026
- sugarforever/01coder-agent-skills
Use fpl-copilot for development tasks
About
fpl-copilot: A skill for development. This provides functionality for development workflows.
- fpl-copilot
Fpl Copilot by the numbers
- 246 all-time installs (skills.sh)
- +11 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,568 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sugarforever/01coder-agent-skills --skill fpl-copilotAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 246 |
|---|---|
| repo stars | ★ 130 |
| Last updated | June 19, 2026 |
| Repository | sugarforever/01coder-agent-skills ↗ |
What it does
Use fpl-copilot for development tasks
Files
FPL Copilot
Fantasy Premier League data sync, analysis, and squad management.
- FPL data: SQLite database at
~/.fplcopilot/fplcopilot.db - User squads: Markdown files in
~/.fplcopilot/squads/
When to Use
Activate this skill when the user mentions:
- FPL, Fantasy Premier League, fantasy football (UK context)
- Player stats, form, price, points, xG, xA, ICT, ownership
- Transfer advice, who to buy/sell, budget options
- Captain pick, vice-captain, chip timing
- Fixture difficulty, FDR, schedule, easy/hard fixtures
- Gameweek deadline, scores, standings, averages
- Squad management, team composition, formation
- Team analysis: momentum, xG differential, leaky defences, hot attacks
- Rotation pairs, differential picks, value picks
Quick Start
1. Check Data Freshness
sqlite3 ~/.fplcopilot/fplcopilot.db "SELECT * FROM sync_metadata;"If the database doesn't exist or data is stale, sync first.
2. Sync Data
SYNC="${CLAUDE_PLUGIN_ROOT}/skills/fpl-copilot/references/sync.sh"
# First time or daily refresh
$SYNC bootstrap # Teams, gameweeks, ~600 players (~5s)
$SYNC fixtures # All 380 fixtures (~2s)
# On demand — single player's match history
$SYNC player 328 # e.g., Salah's detailed GW-by-GW stats
# Batch — all players' histories (slow, ~60s, rate-limited)
$SYNC player-stats
# Everything at once
$SYNC all
# Bypass freshness checks
$SYNC bootstrap --force3. Query and Analyze
# All queries go through sqlite3
sqlite3 ~/.fplcopilot/fplcopilot.db "SELECT web_name, position, form, total_points, now_cost FROM players ORDER BY form DESC LIMIT 10;"Read references/analysis.md for formulas and example SQL queries. Read references/squad.md for squad management, persistence format, and multi-squad support.
Output Format: HTML vs Markdown
Many FPL outputs are inherently spatial or color-coded — formation, FDR matrix, transfer comparison. For those, generate a self-contained HTML report instead of a markdown table. For quick lookups and one-shot answers, stay in markdown.
When to output HTML
| Output type | Format | Template |
|---|---|---|
| Squad view (formation, bench, totals) | HTML | templates/squad-view.html |
| Fixture difficulty matrix (teams × next N GWs) | HTML | templates/fixture-matrix.html |
| Transfer comparison (out → in, deltas) | HTML | templates/transfer-comparison.html |
| Captain ranking with reasoning | HTML | templates/captain-ranking.html |
| Gameweek strategy report (squad + fixtures + recs) | HTML | templates/gameweek-report.html |
| Single-stat lookup ("Salah's form?") | markdown | — |
| Short reasoning ("Bench Haaland this week?") | markdown | — |
| Deadline, price changes, one-line answers | markdown | — |
| 3-row SQL result | markdown | — |
Heuristic: if the user will refer back to it, share it, or scan it visually → HTML. If they glance and move on → markdown.
Universal rules for HTML output
1. Single self-contained `.html` file. No build step. CSS in <style>, JS in <script>, SVG inlined. 2. Vanilla HTML/CSS/JS only. No Tailwind, no shadcn, no external CDN, no web fonts. 3. Mobile responsive. Include <meta name="viewport" content="width=device-width, initial-scale=1">. Layout survives a phone viewport. 4. Save to ~/.fplcopilot/reports/{YYYY-MM-DD}-{slug}.html. Create the directory with mkdir -p if missing. 5. Tell the user the path after saving. On macOS, offer open <path> to view in their default browser. 6. Adapt the template, don't write from scratch. Read the matching file in templates/, replace the placeholder data with real values from SQL, save the result.
Before generating any HTML report, read `references/html-output.md` for color tokens, typography, the team-color table, the sortable-table snippet, and the list of anti-patterns to avoid.
Reference Docs
Read these BEFORE answering questions in their domain:
| Doc | When to Read |
|---|---|
references/api.md | Understanding FPL API endpoints and data structure |
references/analysis.md | Computing metrics (VAPM, projected points, FDR, momentum, etc.) |
references/squad.md | Squad persistence, management, multi-squad, scoring rules |
references/schema.sql | Understanding database tables and columns |
references/html-output.md | Styling rules, color tokens, team colors, anti-patterns for HTML reports |
Squad Persistence
Squads are stored as markdown files in ~/.fplcopilot/squads/ — one file per squad. This enables multi-squad support (user's own team, friends' teams, draft plans).
Proactive persistence rules — the agent MUST follow these:
1. On squad identification: When the user shares their squad (screenshot, text, or any format), immediately save it to ~/.fplcopilot/squads/. Ask for a name if unclear. 2. On squad changes: When the user makes a transfer, changes captain, uses a chip, or modifies their squad in any way, update the squad file immediately after confirming the change. 3. On conversation start: If the user asks about "my squad" or "my team", check ~/.fplcopilot/squads/ for existing squad files first. List available squads if multiple exist. 4. On analysis: After generating a strategy report or analysis, update the squad file's Notes section with key takeaways. 5. Multi-squad: Users may discuss multiple squads (their own, friends', draft plans). Each gets its own file. The user can specify which squad by name. 6. File naming: Use kebab-case slugs derived from the squad name (e.g., my-fpl-team.md, daves-team.md, wildcard-draft.md).
Read references/squad.md for the full markdown format specification.
Agent Rules
1. Always check freshness before answering data questions. If sync_metadata shows stale data (bootstrap > 6h, fixtures on match day > 2h), run the sync script first. 2. Always check for saved squads when the user asks about "my squad/team". Read ~/.fplcopilot/squads/ before asking the user to re-share. 3. Never guess player IDs. Look up by name:
-- Try exact web_name first, then partial, then full name
SELECT * FROM players WHERE web_name = 'Salah' COLLATE NOCASE;
SELECT * FROM players WHERE web_name LIKE '%salah%' COLLATE NOCASE;
SELECT * FROM players WHERE (first_name || ' ' || last_name) LIKE '%salah%' COLLATE NOCASE;4. Price units: now_cost is in 0.1m units. 130 = £13.0m. Always display as £X.Xm. 5. Position codes: GKP, DEF, MID, FWD (mapped from API's 1, 2, 3, 4). 6. Status codes: a=available, d=doubtful, i=injured, s=suspended, u=unavailable. 7. FDR scale: 1 (very easy) to 5 (very hard). 8. Normalize by price when comparing players: value = points / (cost in millions). 9. Fetch player detail on demand: Only run sync.sh player <id> when the user asks about a specific player's match-by-match performance. Don't batch-fetch unless explicitly needed. 10. Proactively persist squads: Always save/update squad files after any squad-related interaction. Never rely on conversation context alone. 11. Generate HTML for spatial outputs: Any request that maps to a template in templates/ — "plan gameweek" / "next gameweek team" → gameweek-report.html; "show/view my squad", formation, bench → squad-view.html; "compare transfer", out → in → transfer-comparison.html; "captain pick" with reasoning → captain-ranking.html; fixture run / FDR matrix → fixture-matrix.html — MUST produce the HTML file (saved to ~/.fplcopilot/reports/{YYYY-MM-DD}-{slug}.html) and report the path with an open <path> hint on macOS. Markdown is only for one-line / single-stat lookups, short reasoning, deadlines, price changes, or ≤3-row SQL results.
FPL Analysis Playbook
All formulas match the fpl-bot web app. Use these exact calculations for consistent analysis across all agents.
All SQL queries run against: sqlite3 ~/.fplcopilot/fplcopilot.db
---
1. Player Metrics
Projected Points
Hybrid formula weighting recent form more than season average:
projected = points_per_game * 0.4 + recent_avg * 0.6points_per_game: fromplayerstable (season PPG from FPL API)recent_avg: averagetotal_pointsfrom last 4 gameweek stats whereminutes > 0- If < 4 games played,
recent_avgfalls back topoints_per_game
Confidence levels:
high: 4+ games with minutesmedium: 2-3 gameslow: 0-1 games
Projected value: projected_points / (now_cost / 10.0) — points per million
-- Get recent average and confidence for all players
SELECT
p.id, p.web_name, p.points_per_game,
COALESCE(r.recent_avg, p.points_per_game) AS recent_avg,
p.points_per_game * 0.4 + COALESCE(r.recent_avg, p.points_per_game) * 0.6 AS projected,
COALESCE(r.games, 0) AS games_played,
CASE
WHEN COALESCE(r.games, 0) >= 4 THEN 'high'
WHEN COALESCE(r.games, 0) >= 2 THEN 'medium'
ELSE 'low'
END AS confidence
FROM players p
LEFT JOIN (
SELECT player_id, AVG(total_points) AS recent_avg, COUNT(*) AS games
FROM (
SELECT player_id, total_points,
ROW_NUMBER() OVER (PARTITION BY player_id ORDER BY gameweek_id DESC) AS rn
FROM player_gameweek_stats
WHERE minutes > 0
) WHERE rn <= 4
GROUP BY player_id
) r ON p.id = r.player_id
WHERE p.status = 'a'
ORDER BY projected DESC
LIMIT 20;VAPM (Value Added Per Million)
vapm = total_points / (now_cost / 10.0)SELECT web_name, position, total_points, now_cost,
ROUND(total_points * 10.0 / now_cost, 2) AS vapm
FROM players
WHERE minutes > 0
ORDER BY vapm DESC
LIMIT 20;Nailedness
How likely a player starts. Based on minutes played in last 5 gameweeks, capped at 1.0.
nailedness = MIN(1.0, SUM(minutes) / (games * 90))SELECT player_id, p.web_name,
MIN(1.0, CAST(SUM(s.minutes) AS REAL) / (COUNT(*) * 90)) AS nailedness
FROM (
SELECT player_id, minutes,
ROW_NUMBER() OVER (PARTITION BY player_id ORDER BY gameweek_id DESC) AS rn
FROM player_gameweek_stats
) s
JOIN players p ON s.player_id = p.id
WHERE s.rn <= 5
GROUP BY s.player_id
ORDER BY nailedness DESC;ICT Trend
Compare average ICT index of last 3 GWs vs the 3 GWs before that. Requires 6 samples. Positive = improving, negative = declining.
ict_trend = avg(GW[-1..-3]) - avg(GW[-4..-6])SELECT player_id, p.web_name,
AVG(CASE WHEN rn <= 3 THEN ict_index END) AS recent_ict,
AVG(CASE WHEN rn BETWEEN 4 AND 6 THEN ict_index END) AS previous_ict,
ROUND(
AVG(CASE WHEN rn <= 3 THEN ict_index END) -
AVG(CASE WHEN rn BETWEEN 4 AND 6 THEN ict_index END),
2) AS ict_trend
FROM (
SELECT player_id, ict_index,
ROW_NUMBER() OVER (PARTITION BY player_id ORDER BY gameweek_id DESC) AS rn
FROM player_gameweek_stats
) s
JOIN players p ON s.player_id = p.id
WHERE s.rn <= 6
GROUP BY s.player_id
HAVING COUNT(CASE WHEN rn <= 3 THEN 1 END) = 3
AND COUNT(CASE WHEN rn BETWEEN 4 AND 6 THEN 1 END) = 3
ORDER BY ict_trend DESC
LIMIT 20;Sustainable Form
Players who score consistently high (high average + low variance). Rewards reliability over one-off hauls.
sustainability_score = recent_avg / (1 + stdev * 0.3)- Minimum 3 games played with minutes > 0
- Filter:
recent_avg >= 3points per game - Computed over last 6 gameweek stats
SELECT player_id, p.web_name, p.position, t.short_name,
ROUND(AVG(total_points), 1) AS recent_avg,
COUNT(*) AS games,
ROUND(AVG(total_points) / (1.0 + SQRT(AVG(total_points * total_points) - AVG(total_points) * AVG(total_points)) * 0.3), 2) AS sustainability
FROM (
SELECT player_id, total_points,
ROW_NUMBER() OVER (PARTITION BY player_id ORDER BY gameweek_id DESC) AS rn
FROM player_gameweek_stats
WHERE minutes > 0
) s
JOIN players p ON s.player_id = p.id
JOIN teams t ON p.team_id = t.id
WHERE s.rn <= 6
GROUP BY s.player_id
HAVING COUNT(*) >= 3 AND AVG(total_points) >= 3
ORDER BY sustainability DESC
LIMIT 15;Availability Flags
Determine a player's availability for display:
| Status | Chance of Playing | Flag | Label |
|---|---|---|---|
u | any | Unavailable | AFCON, loan, etc. |
i or s | any | Injured/Suspended | Out |
d | any | Doubtful | 25-75% likely |
a | null or 100 | Available | No flag needed |
a | >= 75 | Likely | Probably plays |
a | 25-74 | Doubtful | Uncertain |
a | < 25 | Unlikely | Probably out |
-- Players with availability concerns
SELECT web_name, position, t.short_name, status, chance_of_playing, news,
CASE
WHEN status = 'u' THEN 'UNAVAILABLE'
WHEN status IN ('i', 's') THEN 'OUT'
WHEN status = 'd' THEN 'DOUBTFUL'
WHEN chance_of_playing IS NULL OR chance_of_playing = 100 THEN 'AVAILABLE'
WHEN chance_of_playing >= 75 THEN 'LIKELY'
WHEN chance_of_playing >= 25 THEN 'DOUBTFUL'
ELSE 'UNLIKELY'
END AS availability
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE status != 'a' OR (chance_of_playing IS NOT NULL AND chance_of_playing < 100)
ORDER BY status, chance_of_playing;---
2. Team Metrics
xG Differential
Aggregate expected goals from player gameweek stats grouped by team and venue:
SELECT p.team_id, t.short_name,
ROUND(SUM(CASE WHEN pgs.was_home = 1 THEN pgs.expected_goals ELSE 0 END), 2) AS xg_for_home,
ROUND(SUM(CASE WHEN pgs.was_home = 0 THEN pgs.expected_goals ELSE 0 END), 2) AS xg_for_away,
ROUND(SUM(pgs.expected_goals), 2) AS xg_for_total,
ROUND(SUM(CASE WHEN pgs.was_home = 1 THEN pgs.expected_goals_conceded ELSE 0 END), 2) AS xg_against_home,
ROUND(SUM(CASE WHEN pgs.was_home = 0 THEN pgs.expected_goals_conceded ELSE 0 END), 2) AS xg_against_away,
ROUND(SUM(pgs.expected_goals_conceded), 2) AS xg_against_total,
ROUND(SUM(pgs.expected_goals) - SUM(pgs.expected_goals_conceded), 2) AS xg_diff,
COUNT(DISTINCT pgs.fixture_id) AS matches
FROM player_gameweek_stats pgs
JOIN players p ON pgs.player_id = p.id
JOIN teams t ON p.team_id = t.id
WHERE pgs.minutes > 0
GROUP BY p.team_id
ORDER BY xg_diff DESC;Team Momentum
Compare rolling (last 3 matches) xG averages vs season averages. Uses delta (not ratio):
attack_delta = rolling_xg_for_per_match - season_xg_for_per_match
defence_delta = season_xg_against_per_match - rolling_xg_against_per_matchThresholds (per match, +- 0.3):
| Signal | Condition | Meaning | Actionable |
|---|---|---|---|
| Hot attack | attack_delta >= 0.3 | Creating more than usual | Own their attackers |
| Cold attack | attack_delta <= -0.3 | Creating less | Target with defenders (clean sheets) |
| Leaky defence | defence_delta <= -0.3 | Conceding more | Target with attackers/midfielders |
| Solid defence | defence_delta >= -0.3 | Conceding less | Avoid targeting |
| Stable | Between thresholds | No significant change | N/A |
To compute, you need to: 1. Get season per-match xG averages (from xG Differential query above, divide totals by matches) 2. Get last 3 fixtures for each team and aggregate xG from player stats for those fixtures 3. Compare the two
Weighted FDR
Adjusts raw Fixture Difficulty Rating using team strength matchup:
weighted_fdr = base_fdr * venue_multiplier * strength_adjustmentWhere:
venue_multiplier: 0.92 (home) or 1.08 (away)defence_factor = (opponent_attack_strength - team_defence_strength) / 1000attack_factor = (team_attack_strength - opponent_defence_strength) / 1000strength_adjustment = 1 + defence_factor - attack_factor * 0.5- Clamped to 1.0 - 5.0, rounded to 1 decimal
Which strength columns to use:
- Home team:
strength_attack_home,strength_defence_home; opponent uses_awayvariants - Away team:
strength_attack_away,strength_defence_away; opponent uses_homevariants
-- Upcoming fixtures with weighted FDR for a team
SELECT f.id, f.gameweek_id, f.kickoff_time,
CASE WHEN f.home_team_id = {TEAM_ID} THEN 1 ELSE 0 END AS is_home,
CASE WHEN f.home_team_id = {TEAM_ID} THEN at.short_name ELSE ht.short_name END AS opponent,
CASE WHEN f.home_team_id = {TEAM_ID} THEN f.home_difficulty ELSE f.away_difficulty END AS base_fdr,
-- Compute weighted FDR inline
ROUND(MIN(5.0, MAX(1.0,
(CASE WHEN f.home_team_id = {TEAM_ID} THEN f.home_difficulty ELSE f.away_difficulty END)
* (CASE WHEN f.home_team_id = {TEAM_ID} THEN 0.92 ELSE 1.08 END)
* (1.0
+ (CASE WHEN f.home_team_id = {TEAM_ID}
THEN (at.strength_attack_away - ht.strength_defence_home) ELSE (ht.strength_attack_home - at.strength_defence_away) END) / 1000.0
- (CASE WHEN f.home_team_id = {TEAM_ID}
THEN (ht.strength_attack_home - at.strength_defence_away) ELSE (at.strength_attack_away - ht.strength_defence_home) END) * 0.5 / 1000.0
)
)), 1) AS weighted_fdr
FROM fixtures f
JOIN teams ht ON f.home_team_id = ht.id
JOIN teams at ON f.away_team_id = at.id
WHERE (f.home_team_id = {TEAM_ID} OR f.away_team_id = {TEAM_ID})
AND f.finished = 0
ORDER BY f.gameweek_id
LIMIT 6;FDR Labels:
| Range | Label |
|---|---|
| <= 1.5 | Very Easy |
| <= 2.5 | Easy |
| <= 3.5 | Medium |
| <= 4.5 | Hard |
| > 4.5 | Very Hard |
---
3. Decision Frameworks
Captain Pick
Rank candidates by fixture-adjusted projected points:
captain_score = projected_points * (6 - opponent_weighted_fdr) / 5Present the top 3 options with:
- Projected points
- Opponent + venue (H/A) + weighted FDR
- Recent form (last 3 GW points)
- Reasoning
Transfer Suggestions
1. Filter targets: same position as outgoing player, affordable within budget 2. Compute: projected_gain = target.projected - current.projected 3. Only suggest if gain > 0 4. Sort by projected gain descending, return top 5 per transfer slot
Reason tiers (check in order, use first match):
- Form-based: when target's
recent_avg > current * 1.3— "{name} is in better recent form ({X} vs {Y} pts/game)" - Value-based: when target's
projected_value > current * 1.2— "{name} offers better value ({X} vs {Y} pts/m)" - Points-based: default — "{name} has higher projected points ({X} vs {Y})"
Rotation Pairs
Find two budget players at the same position with complementary home/away schedules:
1. Filter candidates: available, position = DEF or GKP 2. Default max price: GKP <= £4.5m (45 units), DEF <= £5.0m (50 units) 3. Take top 50 by total points 4. For each pair (must be different teams):
- Match their fixtures by gameweek
- Count gameweeks with perfect H/A split (one home, one away)
rotation_score = (perfect_rotations / total_gameweeks) * 100
5. Each GW: recommend the player with lower weighted FDR 6. Sort by rotation score desc, then combined price asc. Return top 10.
Chip Timing
| Chip | When to Use |
|---|---|
| Bench Boost | When all 15 squad players have easy fixtures (FDR <= 2) |
| Triple Captain | On highest-projected player with FDR 1-2 |
| Wildcard | When 5+ squad players have FDR >= 4 over next 5 GWs |
| Free Hit | For blank/double gameweeks or fixture pile-ups |
-- Check chips remaining
SELECT chip_type FROM chip_usage;
-- Available: WILDCARD, FREE_HIT, BENCH_BOOST, TRIPLE_CAPTAIN minus what's already used---
4. Common Queries
Top Players by Form
SELECT p.web_name, p.position, t.short_name, p.form, p.total_points,
ROUND(p.now_cost / 10.0, 1) AS price
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE p.status = 'a'
ORDER BY p.form DESC
LIMIT 15;Best Value Players
SELECT p.web_name, p.position, t.short_name, p.total_points,
ROUND(p.now_cost / 10.0, 1) AS price,
ROUND(p.total_points * 10.0 / p.now_cost, 2) AS vapm
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE p.status = 'a' AND p.minutes > 500
ORDER BY vapm DESC
LIMIT 15;Budget Picks by Position
SELECT p.web_name, t.short_name, p.form, p.total_points,
ROUND(p.now_cost / 10.0, 1) AS price,
ROUND(p.total_points * 10.0 / p.now_cost, 2) AS vapm
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE p.position = '{POSITION}' AND p.status = 'a'
AND p.now_cost <= {MAX_COST} -- e.g., 60 for £6.0m
ORDER BY vapm DESC
LIMIT 10;Player Match History
Requires sync.sh player <id> first:
SELECT pgs.gameweek_id AS gw, t.short_name AS opponent,
CASE WHEN pgs.was_home THEN 'H' ELSE 'A' END AS venue,
pgs.total_points AS pts, pgs.minutes AS mins,
pgs.goals AS g, pgs.assists AS a, pgs.bonus AS bns,
ROUND(pgs.expected_goals, 2) AS xg,
ROUND(pgs.expected_assists, 2) AS xa
FROM player_gameweek_stats pgs
JOIN teams t ON pgs.opponent_team_id = t.id
WHERE pgs.player_id = {PLAYER_ID}
ORDER BY pgs.gameweek_id DESC;Differential Picks (Low Ownership, High Form)
SELECT p.web_name, p.position, t.short_name, p.form, p.selected_by_percent,
ROUND(p.now_cost / 10.0, 1) AS price
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE p.status = 'a' AND p.form >= 5.0 AND p.selected_by_percent < 10.0
ORDER BY p.form DESC;FPL API Reference
Base URL: https://fantasy.premierleague.com/api
No authentication required. All endpoints are public and free.
Endpoints
GET /bootstrap-static/
Returns all core FPL data in a single response.
Response:
{
"events": [...], // Gameweeks (38 items)
"teams": [...], // Premier League teams (20 items)
"elements": [...], // Players (~600 items)
"element_types": [...] // Position definitions (4 items)
}events[] (Gameweeks)
| Field | Type | Maps To |
|---|---|---|
id | int | gameweeks.id |
name | string | gameweeks.name |
deadline_time | string (ISO) | gameweeks.deadline_time |
finished | bool | gameweeks.finished |
is_current | bool | gameweeks.is_current |
is_next | bool | gameweeks.is_next |
average_entry_score | int\ | null |
highest_score | int\ | null |
teams[] (Premier League Teams)
| Field | Type | Maps To |
|---|---|---|
id | int | teams.id (1-20) |
name | string | teams.name |
short_name | string | teams.short_name |
code | int | teams.code |
strength | int | teams.strength |
strength_attack_home | int | teams.strength_attack_home |
strength_attack_away | int | teams.strength_attack_away |
strength_defence_home | int | teams.strength_defence_home |
strength_defence_away | int | teams.strength_defence_away |
elements[] (Players)
| Field | Type | Maps To |
|---|---|---|
id | int | players.id |
code | int | players.fpl_code |
first_name | string | players.first_name |
second_name | string | players.last_name |
web_name | string | players.web_name |
element_type | int | players.position (1→GKP, 2→DEF, 3→MID, 4→FWD) |
team | int | players.team_id |
now_cost | int | players.now_cost (0.1m units, 130 = £13.0m) |
total_points | int | players.total_points |
points_per_game | string | players.points_per_game (cast to float) |
form | string | players.form (cast to float, last 3 GW avg) |
selected_by_percent | string | players.selected_by_percent (cast to float) |
minutes | int | players.minutes |
goals_scored | int | players.goals |
assists | int | players.assists |
clean_sheets | int | players.clean_sheets |
goals_conceded | int | players.goals_conceded |
own_goals | int | players.own_goals |
penalties_saved | int | players.penalties_saved |
penalties_missed | int | players.penalties_missed |
yellow_cards | int | players.yellow_cards |
red_cards | int | players.red_cards |
saves | int | players.saves |
bonus | int | players.bonus |
expected_goals | string | players.expected_goals (cast to float) |
expected_assists | string | players.expected_assists (cast to float) |
expected_goal_involvements | string | players.expected_goal_involvements (cast to float) |
expected_goals_conceded | string | players.expected_goals_conceded (cast to float) |
ict_index | string | players.ict_index (cast to float) |
influence | string | players.influence (cast to float) |
creativity | string | players.creativity (cast to float) |
threat | string | players.threat (cast to float) |
status | string | players.status (a/d/i/s/u) |
chance_of_playing_next_round | int\ | null |
news | string | players.news |
element_types[] (Positions)
| id | singular_name | short |
|---|---|---|
| 1 | Goalkeeper | GKP |
| 2 | Defender | DEF |
| 3 | Midfielder | MID |
| 4 | Forward | FWD |
---
GET /fixtures/
Returns all 380 fixtures for the season.
Response: Array of fixture objects.
| Field | Type | Maps To |
|---|---|---|
id | int | fixtures.id |
event | int\ | null |
team_h | int | fixtures.home_team_id |
team_a | int | fixtures.away_team_id |
kickoff_time | string\ | null |
finished | bool | fixtures.finished |
team_h_score | int\ | null |
team_a_score | int\ | null |
team_h_difficulty | int | fixtures.home_difficulty (FDR 1-5) |
team_a_difficulty | int | fixtures.away_difficulty (FDR 1-5) |
---
GET /element-summary/{player_id}/
Returns detailed per-gameweek history for a single player.
Response:
{
"fixtures": [...], // Upcoming fixtures for this player
"history": [...], // Past gameweek stats (what we sync)
"history_past": [...] // Previous season summaries
}history[] (Per-Gameweek Stats)
| Field | Type | Maps To |
|---|---|---|
element | int | player_gameweek_stats.player_id |
round | int | player_gameweek_stats.gameweek_id |
fixture | int | player_gameweek_stats.fixture_id |
opponent_team | int | player_gameweek_stats.opponent_team_id |
was_home | bool | player_gameweek_stats.was_home |
total_points | int | player_gameweek_stats.total_points |
minutes | int | player_gameweek_stats.minutes |
goals_scored | int | player_gameweek_stats.goals |
assists | int | player_gameweek_stats.assists |
clean_sheets | int | player_gameweek_stats.clean_sheets |
goals_conceded | int | player_gameweek_stats.goals_conceded |
own_goals | int | player_gameweek_stats.own_goals |
penalties_saved | int | player_gameweek_stats.penalties_saved |
penalties_missed | int | player_gameweek_stats.penalties_missed |
yellow_cards | int | player_gameweek_stats.yellow_cards |
red_cards | int | player_gameweek_stats.red_cards |
saves | int | player_gameweek_stats.saves |
bonus | int | player_gameweek_stats.bonus |
bps | int | player_gameweek_stats.bps |
expected_goals | string | player_gameweek_stats.expected_goals (cast) |
expected_assists | string | player_gameweek_stats.expected_assists (cast) |
expected_goal_involvements | string | player_gameweek_stats.expected_goal_involvements (cast) |
expected_goals_conceded | string | player_gameweek_stats.expected_goals_conceded (cast) |
ict_index | string | player_gameweek_stats.ict_index (cast) |
influence | string | player_gameweek_stats.influence (cast) |
creativity | string | player_gameweek_stats.creativity (cast) |
threat | string | player_gameweek_stats.threat (cast) |
value | int | player_gameweek_stats.value (0.1m units) |
transfers_balance | int | (not stored) |
selected | int | player_gameweek_stats.selected |
transfers_in | int | player_gameweek_stats.transfers_in |
transfers_out | int | player_gameweek_stats.transfers_out |
---
Rate Limiting
The API is public with no documented rate limits, but:
- Space
/element-summary/calls by at least 100ms - Never batch-fetch all ~600 players unless the user explicitly requests it
- Bootstrap and fixtures endpoints handle full-season data in one call — no batching needed
HTML Output Style Guide
Self-contained HTML reports for FPL data. Calm, typographic, data-dense — like a stats supplement, not a SaaS dashboard.
Color tokens
Copy these CSS variables into every report's <style> block:
:root {
--pl-purple: #37003c; /* primary brand */
--pl-teal: #00ff87; /* accent */
--pl-magenta: #e90052; /* alert / hard fixture */
--fdr-1: #00ff87; /* very easy */
--fdr-2: #91dfb3;
--fdr-3: #e8e8e8;
--fdr-4: #ff6b85;
--fdr-5: #e90052; /* very hard */
--bg: #fafafa;
--surface: #ffffff;
--text: #1a1a1a;
--text-muted: #6b6b6b;
--border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--surface: #161616;
--text: #f0f0f0;
--text-muted: #9b9b9b;
--border: #2a2a2a;
}
}Typography
body {
font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.55;
max-width: 1100px;
margin: 2rem auto;
padding: 0 1.25rem;
color: var(--text);
background: var(--bg);
}
h1, h2, h3 {
font-family: Georgia, "Times New Roman", serif;
font-weight: 600;
letter-spacing: -0.01em;
}
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.25rem; margin-top: 2rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
th { font-weight: 600; font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; user-select: none; }Team identity
Render each team as a colored dot + 3-letter code. No external assets.
<span class="team-badge" style="--team-color: #c8102e">
<span class="team-dot"></span>LIV
</span>.team-badge { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 600; font-size: 0.85rem; }
.team-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--team-color); border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0; }Team color reference (2025-26 PL season)
Look up actual season-current teams via SELECT id, short_name, name FROM teams ORDER BY id;. Use this table to pick colors.
| Code | Team | Color |
|---|---|---|
| ARS | Arsenal | #ef0107 |
| AVL | Aston Villa | #95bfe5 |
| BOU | Bournemouth | #da291c |
| BRE | Brentford | #e30613 |
| BHA | Brighton | #0057b8 |
| BUR | Burnley | #6c1d45 |
| CHE | Chelsea | #034694 |
| CRY | Crystal Palace | #1b458f |
| EVE | Everton | #003399 |
| FUL | Fulham | #000000 |
| LEE | Leeds | #ffcd00 |
| LIV | Liverpool | #c8102e |
| MCI | Man City | #6cabdd |
| MUN | Man United | #da291c |
| NEW | Newcastle | #241f20 |
| NFO | Nottm Forest | #dd0000 |
| SUN | Sunderland | #eb172b |
| TOT | Tottenham | #132257 |
| WHU | West Ham | #7a263a |
| WOL | Wolves | #fdb913 |
If a team isn't in this table, fall back to #6b6b6b (neutral gray) and let the 3-letter code carry identification.
FDR cells
Color the cell background by FDR. Show opponent code + H/A as text.
<td class="fdr-2"><span class="opp">CHE (H)</span></td>.fdr-1 { background: var(--fdr-1); color: #0a4a2c; }
.fdr-2 { background: var(--fdr-2); color: #0a4a2c; }
.fdr-3 { background: var(--fdr-3); color: #1a1a1a; }
.fdr-4 { background: var(--fdr-4); color: #5a1020; }
.fdr-5 { background: var(--fdr-5); color: #ffffff; }Status indicators
<span class="status status-d" title="Doubtful">D</span>
<span class="status status-i" title="Injured">I</span>
<span class="status status-s" title="Suspended">S</span>.status { display: inline-block; padding: 0 0.35rem; border-radius: 3px; font-size: 0.7rem; font-weight: 700; }
.status-a { background: var(--fdr-1); color: #0a4a2c; }
.status-d { background: #ffd54f; color: #5a3d00; }
.status-i { background: var(--fdr-5); color: white; }
.status-s { background: #1a1a1a; color: white; }Icons
Inline lucide SVGs. Define once in <defs>, reuse with <use>.
<svg width="0" height="0" style="display:none">
<defs>
<symbol id="i-up" viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="M12 5v14M19 12l-7 7-7-7" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="M2 17h20l-2-9-5 4-3-8-3 8-5-4z" fill="currentColor"/></symbol>
<symbol id="i-shirt" viewBox="0 0 24 24"><path d="M4 4l4-2 4 4 4-4 4 2-2 6h-2v10H8V10H6z" fill="currentColor"/></symbol>
</defs>
</svg>
<svg width="14" height="14" aria-hidden="true"><use href="#i-up"/></svg>Light interactivity — sortable tables
Drop this in any report with a data table. Click a column header to sort.
<table data-sortable>
<thead><tr><th data-type="text">Player</th><th data-type="number">Points</th></tr></thead>
<tbody><!-- rows --></tbody>
</table>
<script>
document.querySelectorAll('table[data-sortable] th').forEach((th, col) => {
th.addEventListener('click', () => {
const tbody = th.closest('table').tBodies[0];
const rows = [...tbody.rows];
const type = th.dataset.type || 'text';
const dir = th.dataset.dir === 'asc' ? -1 : 1;
th.dataset.dir = dir === 1 ? 'asc' : 'desc';
rows.sort((a, b) => {
const av = a.cells[col].dataset.sort ?? a.cells[col].textContent.trim();
const bv = b.cells[col].dataset.sort ?? b.cells[col].textContent.trim();
return type === 'number' ? (parseFloat(av) - parseFloat(bv)) * dir : av.localeCompare(bv) * dir;
});
rows.forEach(r => tbody.appendChild(r));
});
});
</script>For cells where display text differs from sort value (e.g., "CHE (H)" but sort by FDR=2), use data-sort on the <td>.
Collapsible sections
<details>
<summary>Why this captain pick?</summary>
<p>Salah has averaged 7.2 ppg over the last 5 GWs, faces a side that's conceded 9 goals in their last 6 matches, and has a 38% ownership floor that limits downside.</p>
</details>details { margin: 0.5rem 0; }
summary { cursor: pointer; font-weight: 600; color: var(--pl-purple); }
details[open] summary { margin-bottom: 0.5rem; }Anti-patterns — actively avoid
- Gradient cards per player. Each player is a row or position node, not a hero card.
- Glass morphism, blur backgrounds, neumorphism. Calm and flat.
- Tailwind / shadcn / any CSS framework. Pure vanilla.
- Decorative emoji in headers. Use SVG icons or nothing.
- Bouncy or scale-on-hover animations. Color transitions only.
- Four shades of purple. Stick to the tokens above.
- A "dashboard" feel with 12 KPI cards across the top. This is a stats report, not a marketing page.
- External icon CDN, web fonts, image URLs. Everything inlined.
- `<div>`-based tables. Use
<table>,<th>,<td>— sortable, semantic, screen-reader-friendly.
Output mechanics
- Path:
~/.fplcopilot/reports/{YYYY-MM-DD}-{slug}.html - Slug: kebab-case derived from the report subject (e.g.
gw14-strategy,salah-vs-haaland,wildcard-draft-v2) - Create the directory if it doesn't exist:
mkdir -p ~/.fplcopilot/reports - After saving, tell the user the path. On macOS, offer
open <path>.
-- FPL Copilot SQLite Schema
-- All FPL data + user squad management in a single file database.
-- ============================================
-- FPL Data Tables
-- ============================================
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY, -- FPL team ID (1-20)
name TEXT NOT NULL, -- e.g., "Arsenal"
short_name TEXT NOT NULL, -- e.g., "ARS"
code INTEGER NOT NULL, -- FPL team code
strength INTEGER NOT NULL, -- Overall strength rating
strength_attack_home INTEGER NOT NULL,
strength_attack_away INTEGER NOT NULL,
strength_defence_home INTEGER NOT NULL,
strength_defence_away INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS gameweeks (
id INTEGER PRIMARY KEY, -- GW number (1-38)
name TEXT NOT NULL, -- e.g., "Gameweek 1"
deadline_time TEXT NOT NULL, -- ISO timestamp
finished INTEGER NOT NULL DEFAULT 0,
is_current INTEGER NOT NULL DEFAULT 0,
is_next INTEGER NOT NULL DEFAULT 0,
average_score INTEGER,
highest_score INTEGER,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY, -- FPL element ID
fpl_code INTEGER NOT NULL, -- Stable across seasons
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
web_name TEXT NOT NULL, -- Display name (e.g., "Salah")
position TEXT NOT NULL, -- GKP, DEF, MID, FWD
team_id INTEGER NOT NULL REFERENCES teams(id),
now_cost INTEGER NOT NULL, -- Price in 0.1m units (130 = £13.0m)
total_points INTEGER NOT NULL DEFAULT 0,
points_per_game REAL NOT NULL DEFAULT 0,
form REAL NOT NULL DEFAULT 0,
selected_by_percent REAL NOT NULL DEFAULT 0,
minutes INTEGER NOT NULL DEFAULT 0,
goals INTEGER NOT NULL DEFAULT 0,
assists INTEGER NOT NULL DEFAULT 0,
clean_sheets INTEGER NOT NULL DEFAULT 0,
goals_conceded INTEGER NOT NULL DEFAULT 0,
own_goals INTEGER NOT NULL DEFAULT 0,
penalties_saved INTEGER NOT NULL DEFAULT 0,
penalties_missed INTEGER NOT NULL DEFAULT 0,
yellow_cards INTEGER NOT NULL DEFAULT 0,
red_cards INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
bonus INTEGER NOT NULL DEFAULT 0,
expected_goals REAL NOT NULL DEFAULT 0,
expected_assists REAL NOT NULL DEFAULT 0,
expected_goal_involvements REAL NOT NULL DEFAULT 0,
expected_goals_conceded REAL NOT NULL DEFAULT 0,
ict_index REAL NOT NULL DEFAULT 0,
influence REAL NOT NULL DEFAULT 0,
creativity REAL NOT NULL DEFAULT 0,
threat REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'a', -- a/d/i/s/u
chance_of_playing INTEGER, -- 0-100, nullable
news TEXT, -- Injury/suspension info
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_players_team_id ON players(team_id);
CREATE INDEX IF NOT EXISTS idx_players_position ON players(position);
CREATE INDEX IF NOT EXISTS idx_players_now_cost ON players(now_cost);
CREATE TABLE IF NOT EXISTS fixtures (
id INTEGER PRIMARY KEY, -- FPL fixture ID
gameweek_id INTEGER REFERENCES gameweeks(id),
home_team_id INTEGER NOT NULL REFERENCES teams(id),
away_team_id INTEGER NOT NULL REFERENCES teams(id),
kickoff_time TEXT, -- ISO timestamp, nullable
finished INTEGER NOT NULL DEFAULT 0,
home_score INTEGER,
away_score INTEGER,
home_difficulty INTEGER NOT NULL, -- FDR 1-5
away_difficulty INTEGER NOT NULL, -- FDR 1-5
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_fixtures_gameweek_id ON fixtures(gameweek_id);
CREATE INDEX IF NOT EXISTS idx_fixtures_home_team_id ON fixtures(home_team_id);
CREATE INDEX IF NOT EXISTS idx_fixtures_away_team_id ON fixtures(away_team_id);
CREATE INDEX IF NOT EXISTS idx_fixtures_kickoff_time ON fixtures(kickoff_time);
CREATE TABLE IF NOT EXISTS player_gameweek_stats (
player_id INTEGER NOT NULL REFERENCES players(id),
gameweek_id INTEGER NOT NULL REFERENCES gameweeks(id),
fixture_id INTEGER NOT NULL REFERENCES fixtures(id),
opponent_team_id INTEGER NOT NULL,
was_home INTEGER NOT NULL, -- 0 or 1
total_points INTEGER NOT NULL DEFAULT 0,
minutes INTEGER NOT NULL DEFAULT 0,
goals INTEGER NOT NULL DEFAULT 0,
assists INTEGER NOT NULL DEFAULT 0,
clean_sheets INTEGER NOT NULL DEFAULT 0,
goals_conceded INTEGER NOT NULL DEFAULT 0,
own_goals INTEGER NOT NULL DEFAULT 0,
penalties_saved INTEGER NOT NULL DEFAULT 0,
penalties_missed INTEGER NOT NULL DEFAULT 0,
yellow_cards INTEGER NOT NULL DEFAULT 0,
red_cards INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
bonus INTEGER NOT NULL DEFAULT 0,
bps INTEGER NOT NULL DEFAULT 0,
expected_goals REAL NOT NULL DEFAULT 0,
expected_assists REAL NOT NULL DEFAULT 0,
expected_goal_involvements REAL NOT NULL DEFAULT 0,
expected_goals_conceded REAL NOT NULL DEFAULT 0,
ict_index REAL NOT NULL DEFAULT 0,
influence REAL NOT NULL DEFAULT 0,
creativity REAL NOT NULL DEFAULT 0,
threat REAL NOT NULL DEFAULT 0,
value INTEGER NOT NULL, -- Price at this GW (0.1m units)
transfers_in INTEGER NOT NULL DEFAULT 0,
transfers_out INTEGER NOT NULL DEFAULT 0,
selected INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_id, gameweek_id, fixture_id)
);
CREATE INDEX IF NOT EXISTS idx_pgs_player_id ON player_gameweek_stats(player_id);
CREATE INDEX IF NOT EXISTS idx_pgs_gameweek_id ON player_gameweek_stats(gameweek_id);
CREATE INDEX IF NOT EXISTS idx_pgs_fixture_id ON player_gameweek_stats(fixture_id);
CREATE INDEX IF NOT EXISTS idx_pgs_opponent_team_id ON player_gameweek_stats(opponent_team_id);
-- ============================================
-- Sync Metadata
-- ============================================
CREATE TABLE IF NOT EXISTS sync_metadata (
id TEXT PRIMARY KEY DEFAULT 'singleton',
last_bootstrap_sync TEXT,
last_fixtures_sync TEXT,
last_player_stats_sync TEXT,
last_synced_gameweek INTEGER
);
INSERT OR IGNORE INTO sync_metadata (id) VALUES ('singleton');
-- ============================================
-- User squads are stored as markdown files in ~/.fplcopilot/squads/
-- See references/squad.md for the format specification.
-- ============================================
FPL Squad Management
Squads are stored as markdown files in ~/.fplcopilot/squads/ — one file per squad.
---
1. Squad File Format
Each squad is a markdown file with YAML frontmatter and structured tables:
---
name: MyFplTeam
owner: me
updated: 2026-04-13
gameweek: 32
formation: 3-5-2
---
## Starting XI
| # | Pos | Player | Team | Role |
|---|-----|--------|------|------|
| 1 | GKP | Mamardashvili | LIV | |
| 2 | DEF | Saliba | ARS | |
| 3 | DEF | Virgil | LIV | |
| 4 | DEF | Van Hecke | BHA | |
| 5 | MID | Mbeumo | MUN | |
| 6 | MID | Semenyo | MCI | |
| 7 | MID | Cunha | MUN | |
| 8 | MID | Szoboszlai | LIV | VC |
| 9 | MID | Wilson | FUL | |
| 10 | FWD | Thiago | BRE | C |
| 11 | FWD | Haaland | MCI | |
## Bench
| # | Pos | Player | Team |
|---|-----|--------|------|
| 12 | GKP | Donnarumma | MCI |
| 13 | DEF | Senesi | BOU |
| 14 | FWD | Kroupi.Jr | BOU |
| 15 | MID | Solomon | TOT |
## Chips Used
- (none)
## Transfer Log
| GW | Out | In | Date |
|----|-----|----|------|
## Notes
- Free-form notes, strategy context, things to remember across sessionsFrontmatter Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Squad/team name as shown in FPL app |
owner | Yes | Who owns this squad: me, a friend's name, or draft for planning |
updated | Yes | Date of last update (YYYY-MM-DD) |
gameweek | Yes | Gameweek this squad state reflects |
formation | Yes | Current formation (e.g., 3-5-2, 4-4-2) |
Table Columns
- #: Squad position (1-11 starting, 12-15 bench)
- Pos: GKP, DEF, MID, FWD
- Player:
web_namefrom FPL data (must match exactly for lookups) - Team: Short name (ARS, LIV, MCI, etc.)
- Role:
C(captain),VC(vice-captain), or blank
File Naming
Use kebab-case derived from the squad name:
- "MyFplTeam" →
my-fpl-team.md - "Dave's Team" →
daves-team.md - "Wildcard Draft" →
wildcard-draft.md
---
2. Proactive Persistence Rules
The agent MUST follow these rules — squad persistence is not optional:
When to Save
| Trigger | Action |
|---|---|
| User shares a squad (screenshot, text, list) | Create a new squad file. Ask for a name if unclear. |
| User makes a transfer | Update Starting XI / Bench tables + add to Transfer Log |
| User changes captain or VC | Update the Role column |
| User uses a chip | Add to Chips Used section |
| User changes formation | Update the tables + frontmatter formation |
| Strategy analysis completed | Append key findings to Notes section |
| User discusses a friend's squad | Create a separate squad file with owner: <friend's name> |
When to Load
| Trigger | Action |
|---|---|
| User says "my squad/team" | List files in ~/.fplcopilot/squads/, load the one with owner: me (or ask if multiple) |
| User names a specific squad | Load that squad file |
| User asks for analysis/advice | Load relevant squad file to provide personalized recommendations |
| Conversation starts with FPL context | Check for existing squads proactively |
Always Update
After any squad modification: 1. Update the relevant table (Starting XI, Bench, Chips, Transfer Log) 2. Update updated date in frontmatter 3. Update gameweek if it has changed 4. Write the file back
---
3. Multi-Squad Support
Users may track multiple squads:
- Their own team:
owner: me— primary squad for personalized advice - Friends' teams:
owner: Dave— for comparison or helping friends - Draft plans:
owner: draft— hypothetical squads for planning (wildcard, free hit)
When multiple squad files exist and the user says "my squad", prefer the file with owner: me. If multiple owner: me files exist, list them and ask which one.
---
4. Squad Rules
These rules apply when validating or building squads:
| Rule | Constraint |
|---|---|
| Squad size | Exactly 15 players: 2 GKP, 5 DEF, 5 MID, 3 FWD |
| Starting XI | 11 players: exactly 1 GKP, 3+ DEF, 2+ MID, 1+ FWD |
| Max per team | 3 players from any one Premier League team |
| Budget | Total now_cost <= 1000 (= £100.0m) |
| Captain | Exactly 1 C + 1 VC, both from Starting XI |
| Chips | Each usable once per season: WILDCARD, FREE_HIT, BENCH_BOOST, TRIPLE_CAPTAIN |
Valid Formations
Any formation with exactly 1 GKP and at least 3 DEF, 2 MID, 1 FWD:
- 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-2-3, 5-3-2, 5-4-1
Validation
Before saving a squad, verify against FPL data:
-- Check position composition
SELECT position, COUNT(*) FROM players
WHERE web_name IN ('Player1', 'Player2', ...) COLLATE NOCASE
GROUP BY position;
-- Check max 3 per team
SELECT team_id, COUNT(*) FROM players
WHERE web_name IN (...) COLLATE NOCASE
GROUP BY team_id HAVING COUNT(*) > 3;
-- Check budget
SELECT SUM(now_cost) FROM players
WHERE web_name IN (...) COLLATE NOCASE;---
5. Operations
Make Transfer
1. Validate: same position, affordable, max-3-per-team after swap 2. Update Starting XI or Bench table: replace the player 3. Add entry to Transfer Log table 4. Update frontmatter updated date
Set Captain / Vice-Captain
1. Remove existing C or VC from Role column 2. Set new C or VC (must be in Starting XI) 3. Save file
Use Chip
1. Check Chips Used section — must not already be used 2. Add to Chips Used: - BENCH_BOOST (GW33) 3. Save file
Change Formation
1. Move players between Starting XI and Bench 2. Validate new formation (1 GKP, 3+ DEF, 2+ MID, 1+ FWD) 3. Update frontmatter formation 4. Renumber squad positions 5. Save file
---
6. Score Calculation
Captain Multiplier
- Captain: 2x points (or 3x with Triple Captain chip)
- If captain has 0 minutes, vice-captain gets the multiplier
Auto-Substitution
When a starter has 0 minutes, substitute from bench in order (#12, #13, #14, #15):
1. Find first bench player who played (minutes > 0) 2. Verify substitution maintains valid formation (1 GKP, 3+ DEF, 2+ MID, 1+ FWD) 3. If valid, make the sub. If not, try next bench player.
Bench Boost
When active, all bench players' points count (no auto-sub needed).
---
7. Squad Analysis
When analyzing a squad, combine the squad file with FPL data:
Squad Health
Check each player's status and form against the players table:
SELECT p.web_name, p.position, t.short_name, p.form, p.status,
p.chance_of_playing, p.news
FROM players p
JOIN teams t ON p.team_id = t.id
WHERE p.web_name IN ({squad_player_names}) COLLATE NOCASE
AND (p.status != 'a' OR p.form < 2.0)
ORDER BY p.form;Fixture Outlook
Get upcoming fixtures for all squad players' teams — see analysis.md for weighted FDR queries.
Transfer Suggestions
Compare squad players' projected points with available alternatives — see analysis.md for the transfer suggestion framework.
#!/usr/bin/env bash
# FPL Copilot — Data Sync Script
# Syncs Fantasy Premier League data into a local SQLite database.
#
# Usage:
# sync.sh bootstrap # Teams, gameweeks, players
# sync.sh fixtures # All fixtures
# sync.sh player <id> # Single player's match-by-match history
# sync.sh player-stats # All players' histories (~60s)
# sync.sh all # bootstrap + fixtures + player-stats
#
# Options:
# --force # Bypass freshness checks
set -euo pipefail
# ============================================
# Configuration
# ============================================
FPL_API="https://fantasy.premierleague.com/api"
DATA_DIR="${HOME}/.fplcopilot"
DB="${DATA_DIR}/fplcopilot.db"
SCHEMA_DIR="$(cd "$(dirname "$0")" && pwd)"
SCHEMA="${SCHEMA_DIR}/schema.sql"
BOOTSTRAP_TTL=21600 # 6 hours in seconds
RATE_LIMIT_MS=0.1 # 100ms between player API calls
# ============================================
# Helpers
# ============================================
log() { echo "[FPL Copilot] $*"; }
warn() { echo "[FPL Copilot] WARNING: $*" >&2; }
die() { echo "[FPL Copilot] ERROR: $*" >&2; exit 1; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
seconds_since() {
local ts="$1"
if [ -z "$ts" ] || [ "$ts" = "null" ]; then
echo 999999
return
fi
local now_epoch ts_epoch
now_epoch=$(date -u +%s)
if date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s >/dev/null 2>&1; then
ts_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null || echo 0)
else
ts_epoch=$(date -d "$ts" +%s 2>/dev/null || echo 0)
fi
echo $(( now_epoch - ts_epoch ))
}
sql() { sqlite3 "$DB" "$@"; }
# Import CSV data into a table via INSERT OR REPLACE
# Usage: csv_upsert <table_name> <csv_file> <column_list>
csv_upsert() {
local table="$1" csvfile="$2" columns="$3"
local tmptable="_import_${table}"
# Create a temp table with no constraints, import CSV, then upsert
sql <<EOSQL
.mode csv
CREATE TEMP TABLE IF NOT EXISTS ${tmptable} AS SELECT ${columns} FROM ${table} LIMIT 0;
DELETE FROM ${tmptable};
.import --skip 1 ${csvfile} ${tmptable}
INSERT OR REPLACE INTO ${table} (${columns}) SELECT ${columns} FROM ${tmptable};
DROP TABLE IF EXISTS ${tmptable};
EOSQL
}
# ============================================
# Init
# ============================================
init_db() {
mkdir -p "$DATA_DIR"
if [ ! -f "$DB" ]; then
log "Creating database at $DB"
fi
sql < "$SCHEMA"
}
# ============================================
# JQ Filters (written to temp files to avoid quoting issues)
# ============================================
write_jq_filters() {
JQ_TEAMS=$(mktemp)
cat > "$JQ_TEAMS" << 'JQEOF'
def map_pos: if . == 1 then "GKP" elif . == 2 then "DEF" elif . == 3 then "MID" elif . == 4 then "FWD" else "MID" end;
def nn: . // "";
def nz: (. | tonumber) // 0;
def bl: if . then 1 else 0 end;
.teams | [.[] | [.id, .name, .short_name, .code, .strength, .strength_attack_home, .strength_attack_away, .strength_defence_home, .strength_defence_away, $now]] | (["id","name","short_name","code","strength","strength_attack_home","strength_attack_away","strength_defence_home","strength_defence_away","updated_at"] | @csv), (.[] | @csv)
JQEOF
JQ_GAMEWEEKS=$(mktemp)
cat > "$JQ_GAMEWEEKS" << 'JQEOF'
def bl: if . then 1 else 0 end;
.events | [.[] | [.id, .name, .deadline_time, (.finished | bl), (.is_current | bl), (.is_next | bl), (.average_entry_score // ""), (.highest_score // ""), $now]] | (["id","name","deadline_time","finished","is_current","is_next","average_score","highest_score","updated_at"] | @csv), (.[] | @csv)
JQEOF
JQ_PLAYERS=$(mktemp)
cat > "$JQ_PLAYERS" << 'JQEOF'
def map_pos: if . == 1 then "GKP" elif . == 2 then "DEF" elif . == 3 then "MID" elif . == 4 then "FWD" else "MID" end;
def nz: (. | tonumber) // 0;
def bl: if . then 1 else 0 end;
.elements | [.[] | [
.id, .code, .first_name, .second_name, .web_name,
(.element_type | map_pos), .team, .now_cost, .total_points,
(.points_per_game | nz), (.form | nz), (.selected_by_percent | nz),
.minutes, .goals_scored, .assists, .clean_sheets, .goals_conceded,
.own_goals, .penalties_saved, .penalties_missed, .yellow_cards,
.red_cards, .saves, .bonus,
(.expected_goals | nz), (.expected_assists | nz),
(.expected_goal_involvements | nz), (.expected_goals_conceded | nz),
(.ict_index | nz), (.influence | nz), (.creativity | nz), (.threat | nz),
.status, (.chance_of_playing_next_round // ""), (.news // ""), $now
]] | (["id","fpl_code","first_name","last_name","web_name","position","team_id","now_cost","total_points","points_per_game","form","selected_by_percent","minutes","goals","assists","clean_sheets","goals_conceded","own_goals","penalties_saved","penalties_missed","yellow_cards","red_cards","saves","bonus","expected_goals","expected_assists","expected_goal_involvements","expected_goals_conceded","ict_index","influence","creativity","threat","status","chance_of_playing","news","updated_at"] | @csv), (.[] | @csv)
JQEOF
JQ_FIXTURES=$(mktemp)
cat > "$JQ_FIXTURES" << 'JQEOF'
def bl: if . then 1 else 0 end;
[.[] | [
.id, (.event // ""), .team_h, .team_a, (.kickoff_time // ""),
(.finished | bl), (.team_h_score // ""), (.team_a_score // ""),
.team_h_difficulty, .team_a_difficulty, $now
]] | (["id","gameweek_id","home_team_id","away_team_id","kickoff_time","finished","home_score","away_score","home_difficulty","away_difficulty","updated_at"] | @csv), (.[] | @csv)
JQEOF
JQ_PLAYER_STATS=$(mktemp)
cat > "$JQ_PLAYER_STATS" << 'JQEOF'
def nz: (. | tonumber) // 0;
def bl: if . then 1 else 0 end;
.history | [.[] | [
.element, .round, .fixture, .opponent_team, (.was_home | bl),
.total_points, .minutes, .goals_scored, .assists, .clean_sheets,
.goals_conceded, .own_goals, .penalties_saved, .penalties_missed,
.yellow_cards, .red_cards, .saves, .bonus, .bps,
(.expected_goals | nz), (.expected_assists | nz),
(.expected_goal_involvements | nz), (.expected_goals_conceded | nz),
(.ict_index | nz), (.influence | nz), (.creativity | nz), (.threat | nz),
.value, .transfers_in, .transfers_out, .selected
]] | (["player_id","gameweek_id","fixture_id","opponent_team_id","was_home","total_points","minutes","goals","assists","clean_sheets","goals_conceded","own_goals","penalties_saved","penalties_missed","yellow_cards","red_cards","saves","bonus","bps","expected_goals","expected_assists","expected_goal_involvements","expected_goals_conceded","ict_index","influence","creativity","threat","value","transfers_in","transfers_out","selected"] | @csv), (.[] | @csv)
JQEOF
}
cleanup_jq_filters() {
rm -f "$JQ_TEAMS" "$JQ_GAMEWEEKS" "$JQ_PLAYERS" "$JQ_FIXTURES" "$JQ_PLAYER_STATS" 2>/dev/null || true
}
# ============================================
# Bootstrap Sync
# ============================================
sync_bootstrap() {
local force="${1:-false}"
if [ "$force" != "true" ]; then
local last_sync
last_sync=$(sql "SELECT last_bootstrap_sync FROM sync_metadata WHERE id='singleton';")
local age
age=$(seconds_since "$last_sync")
if [ "$age" -lt "$BOOTSTRAP_TTL" ]; then
log "Bootstrap data is fresh (synced $(( age / 60 ))m ago). Use --force to override."
return 0
fi
fi
log "Fetching bootstrap-static from FPL API..."
local tmpjson tmpcsv
tmpjson=$(mktemp)
if ! curl -sf "${FPL_API}/bootstrap-static/" -o "$tmpjson"; then
rm -f "$tmpjson"
die "Failed to fetch bootstrap-static"
fi
local now
now=$(now_iso)
# --- Teams ---
local team_count
team_count=$(jq '.teams | length' "$tmpjson")
log "Syncing $team_count teams..."
tmpcsv=$(mktemp)
jq -r --arg now "$now" -f "$JQ_TEAMS" "$tmpjson" > "$tmpcsv"
csv_upsert "teams" "$tmpcsv" "id,name,short_name,code,strength,strength_attack_home,strength_attack_away,strength_defence_home,strength_defence_away,updated_at"
rm -f "$tmpcsv"
# --- Gameweeks ---
local gw_count
gw_count=$(jq '.events | length' "$tmpjson")
log "Syncing $gw_count gameweeks..."
tmpcsv=$(mktemp)
jq -r --arg now "$now" -f "$JQ_GAMEWEEKS" "$tmpjson" > "$tmpcsv"
csv_upsert "gameweeks" "$tmpcsv" "id,name,deadline_time,finished,is_current,is_next,average_score,highest_score,updated_at"
rm -f "$tmpcsv"
# --- Players ---
local player_count
player_count=$(jq '.elements | length' "$tmpjson")
log "Syncing $player_count players..."
tmpcsv=$(mktemp)
jq -r --arg now "$now" -f "$JQ_PLAYERS" "$tmpjson" > "$tmpcsv"
csv_upsert "players" "$tmpcsv" "id,fpl_code,first_name,last_name,web_name,position,team_id,now_cost,total_points,points_per_game,form,selected_by_percent,minutes,goals,assists,clean_sheets,goals_conceded,own_goals,penalties_saved,penalties_missed,yellow_cards,red_cards,saves,bonus,expected_goals,expected_assists,expected_goal_involvements,expected_goals_conceded,ict_index,influence,creativity,threat,status,chance_of_playing,news,updated_at"
rm -f "$tmpcsv"
rm -f "$tmpjson"
sql "UPDATE sync_metadata SET last_bootstrap_sync = '$now' WHERE id = 'singleton';"
log "Bootstrap sync complete: $team_count teams, $gw_count gameweeks, $player_count players"
}
# ============================================
# Fixtures Sync
# ============================================
sync_fixtures() {
local force="${1:-false}"
if [ "$force" != "true" ]; then
local today today_end count
today=$(date -u +%Y-%m-%dT00:00:00Z)
today_end=$(date -u +%Y-%m-%dT23:59:59Z)
count=$(sql "SELECT COUNT(*) FROM fixtures WHERE kickoff_time >= '$today' AND kickoff_time <= '$today_end';" 2>/dev/null || echo "0")
if [ "$count" -eq 0 ] 2>/dev/null; then
log "No fixtures today. Use --force to override."
return 0
fi
fi
log "Fetching fixtures from FPL API..."
local tmpjson tmpcsv
tmpjson=$(mktemp)
if ! curl -sf "${FPL_API}/fixtures/" -o "$tmpjson"; then
rm -f "$tmpjson"
die "Failed to fetch fixtures"
fi
local now
now=$(now_iso)
local fixture_count
fixture_count=$(jq 'length' "$tmpjson")
log "Syncing $fixture_count fixtures..."
tmpcsv=$(mktemp)
jq -r --arg now "$now" -f "$JQ_FIXTURES" "$tmpjson" > "$tmpcsv"
csv_upsert "fixtures" "$tmpcsv" "id,gameweek_id,home_team_id,away_team_id,kickoff_time,finished,home_score,away_score,home_difficulty,away_difficulty,updated_at"
rm -f "$tmpcsv" "$tmpjson"
sql "UPDATE sync_metadata SET last_fixtures_sync = '$now' WHERE id = 'singleton';"
log "Fixtures sync complete: $fixture_count fixtures"
}
# ============================================
# Player Stats Sync (single player)
# ============================================
sync_single_player() {
local player_id="$1"
local tmpjson tmpcsv
tmpjson=$(mktemp)
if ! curl -sf "${FPL_API}/element-summary/${player_id}/" -o "$tmpjson"; then
rm -f "$tmpjson"
warn "Failed to fetch element-summary for player $player_id"
return 1
fi
local stat_count
stat_count=$(jq '.history | length' "$tmpjson")
if [ "$stat_count" -eq 0 ]; then
rm -f "$tmpjson"
return 0
fi
tmpcsv=$(mktemp)
jq -r -f "$JQ_PLAYER_STATS" "$tmpjson" > "$tmpcsv"
csv_upsert "player_gameweek_stats" "$tmpcsv" "player_id,gameweek_id,fixture_id,opponent_team_id,was_home,total_points,minutes,goals,assists,clean_sheets,goals_conceded,own_goals,penalties_saved,penalties_missed,yellow_cards,red_cards,saves,bonus,bps,expected_goals,expected_assists,expected_goal_involvements,expected_goals_conceded,ict_index,influence,creativity,threat,value,transfers_in,transfers_out,selected"
rm -f "$tmpcsv" "$tmpjson"
log "Player $player_id: $stat_count gameweek records synced"
}
# ============================================
# Player Stats Sync (batch)
# ============================================
sync_all_player_stats() {
local force="${1:-false}"
if [ "$force" != "true" ]; then
local last_gw latest_finished
last_gw=$(sql "SELECT last_synced_gameweek FROM sync_metadata WHERE id='singleton';")
latest_finished=$(sql "SELECT MAX(id) FROM gameweeks WHERE finished = 1;")
if [ -z "$latest_finished" ] || [ "$latest_finished" = "" ]; then
log "No finished gameweeks. Nothing to sync."
return 0
fi
if [ -n "$last_gw" ] && [ "$last_gw" != "" ] && [ "$latest_finished" -le "$last_gw" ] 2>/dev/null; then
log "No new finished gameweek (last synced: GW$last_gw, latest finished: GW$latest_finished). Use --force to override."
return 0
fi
log "New gameweek detected: GW$latest_finished (last synced: ${last_gw:-none})"
fi
local player_ids
player_ids=$(sql "SELECT id FROM players ORDER BY id;")
local total
total=$(echo "$player_ids" | wc -l | tr -d ' ')
local success=0 fail=0 current=0
log "Syncing player stats for $total players..."
while IFS= read -r pid; do
[ -z "$pid" ] && continue
current=$(( current + 1 ))
if sync_single_player "$pid" 2>/dev/null; then
success=$(( success + 1 ))
else
fail=$(( fail + 1 ))
fi
if [ $(( current % 50 )) -eq 0 ]; then
log "Progress: $current/$total ($success synced, $fail failed)"
fi
sleep "$RATE_LIMIT_MS"
done <<< "$player_ids"
local latest_finished
latest_finished=$(sql "SELECT MAX(id) FROM gameweeks WHERE finished = 1;")
local now
now=$(now_iso)
sql "UPDATE sync_metadata SET last_player_stats_sync = '$now', last_synced_gameweek = $latest_finished WHERE id = 'singleton';"
log "Player stats sync complete: $success/$total synced ($fail failed)"
}
# ============================================
# Full Sync
# ============================================
sync_all() {
log "Starting full sync..."
sync_bootstrap "true"
sync_fixtures "true"
sync_all_player_stats "true"
log "Full sync complete."
}
# ============================================
# Main
# ============================================
main() {
local command="${1:-}"
local force="false"
for arg in "$@"; do
if [ "$arg" = "--force" ] || [ "$arg" = "-f" ]; then
force="true"
fi
done
for cmd in curl jq sqlite3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
die "Required command not found: $cmd"
fi
done
init_db
write_jq_filters
trap cleanup_jq_filters EXIT
case "$command" in
bootstrap)
sync_bootstrap "$force"
;;
fixtures)
sync_fixtures "$force"
;;
player)
local player_id="${2:-}"
if [ -z "$player_id" ]; then
die "Usage: sync.sh player <player_id>"
fi
sync_single_player "$player_id"
;;
player-stats)
sync_all_player_stats "$force"
;;
all)
sync_all
;;
*)
echo "FPL Copilot Sync"
echo ""
echo "Usage:"
echo " sync.sh bootstrap # Sync teams, gameweeks, players"
echo " sync.sh fixtures # Sync fixtures"
echo " sync.sh player <id> # Sync single player history"
echo " sync.sh player-stats # Sync all player histories"
echo " sync.sh all # Full sync (everything)"
echo ""
echo "Options:"
echo " --force # Bypass freshness checks"
exit 1
;;
esac
}
main "$@"
<!--
TEMPLATE: captain-ranking.html
Replace placeholder data with top N captain options for the GW.
Slots: {gw_num}, {deadline}, {candidates[]} — for each: rank, web_name, team_color, team_code, position, fixture (opp, was_home, fdr), form, xpts, ownership_pct, recommendation_score, reasoning.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Captain Picks — GW14</title>
<style>
:root {
--pl-purple: #37003c; --pl-teal: #00ff87; --pl-magenta: #e90052;
--fdr-1: #00ff87; --fdr-2: #91dfb3; --fdr-3: #e8e8e8; --fdr-4: #ff6b85; --fdr-5: #e90052;
--bg: #fafafa; --surface: #ffffff; --text: #1a1a1a; --text-muted: #6b6b6b; --border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --surface: #161616; --text: #f0f0f0; --text-muted: #9b9b9b; --border: #2a2a2a; }
.fdr-3 { background: #4a4a4a !important; color: #f0f0f0 !important; }
}
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif; font-size: 15px; line-height: 1.55; max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem; color: var(--text); background: var(--bg); }
h1 { font-family: Georgia, serif; font-weight: 600; letter-spacing: -0.01em; font-size: 1.75rem; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
.top-pick { background: var(--surface); border: 2px solid var(--pl-teal); border-radius: 8px; padding: 1.25rem 1.5rem; margin: 1.5rem 0; display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; }
.crown-svg { width: 32px; height: 32px; color: var(--pl-magenta); flex-shrink: 0; }
.top-pick-body { flex: 1; min-width: 200px; }
.top-pick .label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 600; }
.top-pick .name { font-family: Georgia, serif; font-size: 1.5rem; font-weight: 600; margin: 0.15rem 0; }
.top-pick .meta { color: var(--text-muted); font-size: 0.9rem; }
.top-pick .xpts { text-align: right; }
.top-pick .xpts-num { font-size: 2rem; font-weight: 700; color: var(--pl-purple); font-variant-numeric: tabular-nums; line-height: 1; }
.top-pick .xpts-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; margin-top: 1rem; }
th, td { padding: 0.6rem 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
th { font-weight: 600; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; user-select: none; background: var(--surface); }
th:hover { color: var(--pl-purple); }
th[data-dir="asc"]::after { content: " ▲"; }
th[data-dir="desc"]::after { content: " ▼"; }
td.num { text-align: right; font-weight: 600; }
.team-badge { display: inline-flex; align-items: center; gap: 0.35rem; }
.team-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--team-color); border: 1px solid rgba(0,0,0,0.15); }
.fix-chip { display: inline-block; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.75rem; font-weight: 600; }
.fdr-1 { background: var(--fdr-1); color: #0a4a2c; }
.fdr-2 { background: var(--fdr-2); color: #0a4a2c; }
.fdr-3 { background: var(--fdr-3); color: #1a1a1a; }
.fdr-4 { background: var(--fdr-4); color: #5a1020; }
.fdr-5 { background: var(--fdr-5); color: #ffffff; }
.away { opacity: 0.75; font-style: italic; }
.row-toggle { cursor: pointer; }
.row-toggle:hover { background: var(--bg); }
.reasoning { background: var(--bg); display: none; }
.reasoning.open { display: table-row; }
.reasoning td { padding: 1rem 1.5rem; color: var(--text-muted); font-size: 0.9rem; border-bottom: 1px solid var(--border); }
.conf { display: inline-block; width: 60px; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; vertical-align: middle; }
.conf-bar { height: 100%; background: var(--pl-teal); }
.rank { font-family: Georgia, serif; font-weight: 600; color: var(--text-muted); }
</style>
</head>
<body>
<svg width="0" height="0" style="display:none"><defs>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="M2 17h20l-2-9-5 4-3-8-3 8-5-4z" fill="currentColor"/></symbol>
</defs></svg>
<h1>Captain Picks</h1>
<p class="subtitle">GW14 · Deadline Saturday 11:30 · 8 candidates ranked</p>
<div class="top-pick">
<svg class="crown-svg" aria-hidden="true"><use href="#i-crown"/></svg>
<div class="top-pick-body">
<div class="label">Top pick</div>
<div class="name">Mohamed Salah</div>
<div class="meta"><span class="team-badge" style="--team-color:#c8102e"><span class="team-dot"></span>LIV</span> · vs <span class="fix-chip fdr-2 away">WOL (A)</span> · Form 8.0 · Ownership 38%</div>
</div>
<div class="xpts">
<div class="xpts-num">8.6</div>
<div class="xpts-label">xPts captained</div>
</div>
</div>
<table data-sortable>
<thead>
<tr>
<th data-type="number">#</th>
<th data-type="text">Player</th>
<th data-type="text">Fixture</th>
<th data-type="number">Form</th>
<th data-type="number">xPts</th>
<th data-type="number">Own %</th>
<th data-type="number">Confidence</th>
</tr>
</thead>
<tbody>
<tr class="row-toggle">
<td class="rank">1</td>
<td><span class="team-badge" style="--team-color:#c8102e"><span class="team-dot"></span></span> Salah</td>
<td><span class="fix-chip fdr-2 away">WOL (A)</span></td>
<td class="num">8.0</td>
<td class="num">8.6</td>
<td class="num">38%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:92%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Salah has averaged 7.2 ppg over the last 5 GWs, faces a Wolves side that's conceded 9 in their last 6. Ownership floor at 38% caps downside vs the field. Reliable double-digit ceiling.</td></tr>
<tr class="row-toggle">
<td class="rank">2</td>
<td><span class="team-badge" style="--team-color:#034694"><span class="team-dot"></span></span> Palmer</td>
<td><span class="fix-chip fdr-3">MCI (H)</span></td>
<td class="num">8.2</td>
<td class="num">8.1</td>
<td class="num">31%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:78%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Form leader with 41 pts last 5. Home vs City is harder than the FDR suggests, but Palmer's set-piece duties and penalty role give him a higher floor than most premium MIDs. Differential edge if City defense holds.</td></tr>
<tr class="row-toggle">
<td class="rank">3</td>
<td><span class="team-badge" style="--team-color:#dd0000"><span class="team-dot"></span></span> Wood</td>
<td><span class="fix-chip fdr-2">LEE (H)</span></td>
<td class="num">7.4</td>
<td class="num">7.2</td>
<td class="num">14%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:65%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Differential punt at 14% ownership. Home vs Leeds with Wood as the lone striker — clear shot-on-goal monopoly. Risk: Forest rotation possible after midweek European fixture.</td></tr>
<tr class="row-toggle">
<td class="rank">4</td>
<td><span class="team-badge" style="--team-color:#241f20"><span class="team-dot"></span></span> Isak</td>
<td><span class="fix-chip fdr-2 away">BUR (A)</span></td>
<td class="num">6.6</td>
<td class="num">7.0</td>
<td class="num">22%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:62%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Burnley away is winnable, but Isak's xG conversion has been streaky — overperformed by 2.1 goals this season, due regression. Solid floor, lower ceiling than the top 3.</td></tr>
<tr class="row-toggle">
<td class="rank">5</td>
<td><span class="team-badge" style="--team-color:#6cabdd"><span class="team-dot"></span></span> Haaland</td>
<td><span class="fix-chip fdr-5 away">CHE (A)</span></td>
<td class="num">4.6</td>
<td class="num">5.8</td>
<td class="num">52%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:48%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Template captain, but Chelsea away is a brutal fixture. Highest base xPts in the model gets dragged down by FDR. If you're chasing rank, sticking with him is defensible; if you're attacking rank, pivot.</td></tr>
<tr class="row-toggle">
<td class="rank">6</td>
<td><span class="team-badge" style="--team-color:#ef0107"><span class="team-dot"></span></span> Saka</td>
<td><span class="fix-chip fdr-2">CHE (H)</span></td>
<td class="num">6.2</td>
<td class="num">6.4</td>
<td class="num">24%</td>
<td class="num"><span class="conf"><span class="conf-bar" style="width:55%"></span></span></td>
</tr>
<tr class="reasoning"><td colspan="7">Home in a London derby, but Saka's xG has been suppressed in the last 3 — touches in the box dropped by 18%. Wait for confirmed minutes before locking in.</td></tr>
</tbody>
</table>
<script>
document.querySelectorAll('table[data-sortable] th').forEach((th, col) => {
th.addEventListener('click', () => {
const tbody = th.closest('table').tBodies[0];
const pairs = [];
for (let i = 0; i < tbody.rows.length; i += 2) pairs.push([tbody.rows[i], tbody.rows[i + 1]]);
const type = th.dataset.type || 'text';
const dir = th.dataset.dir === 'asc' ? -1 : 1;
th.parentElement.querySelectorAll('th').forEach(h => h.removeAttribute('data-dir'));
th.dataset.dir = dir === 1 ? 'asc' : 'desc';
pairs.sort(([a], [b]) => {
const av = a.cells[col].textContent.trim();
const bv = b.cells[col].textContent.trim();
return type === 'number' ? (parseFloat(av) - parseFloat(bv)) * dir : av.localeCompare(bv) * dir;
});
pairs.forEach(([r1, r2]) => { tbody.appendChild(r1); tbody.appendChild(r2); });
});
});
document.querySelectorAll('.row-toggle').forEach(tr => {
tr.addEventListener('click', () => {
const next = tr.nextElementSibling;
if (next && next.classList.contains('reasoning')) next.classList.toggle('open');
});
});
</script>
</body>
</html>
<!--
TEMPLATE: fixture-matrix.html
Replace placeholder data with real fixtures from `fixtures` table joined with `teams`.
Slots: {gw_start}, {gw_end}, {teams[]} — for each team: short_name, team_color, fixtures[] (one per GW: opponent_code, was_home, fdr), avg_fdr.
Sortable by any GW column or Avg FDR. Click a row to highlight.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fixture Difficulty — GW14 → GW19</title>
<style>
:root {
--pl-purple: #37003c; --fdr-1: #00ff87; --fdr-2: #91dfb3; --fdr-3: #e8e8e8; --fdr-4: #ff6b85; --fdr-5: #e90052;
--bg: #fafafa; --surface: #ffffff; --text: #1a1a1a; --text-muted: #6b6b6b; --border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --surface: #161616; --text: #f0f0f0; --text-muted: #9b9b9b; --border: #2a2a2a; }
.fdr-3 { background: #4a4a4a !important; color: #f0f0f0 !important; }
}
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif; font-size: 15px; line-height: 1.55; max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem; color: var(--text); background: var(--bg); }
h1 { font-family: Georgia, serif; font-weight: 600; letter-spacing: -0.01em; font-size: 1.75rem; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
.legend { display: flex; gap: 0.5rem; align-items: center; margin: 1rem 0; font-size: 0.8rem; color: var(--text-muted); }
.legend-cell { display: inline-block; width: 28px; height: 18px; border-radius: 3px; text-align: center; line-height: 18px; font-weight: 700; font-size: 0.75rem; }
table { width: 100%; border-collapse: separate; border-spacing: 2px; font-variant-numeric: tabular-nums; }
th { padding: 0.5rem 0.5rem; text-align: center; font-weight: 600; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; user-select: none; background: var(--surface); border-radius: 4px; }
th:hover { color: var(--pl-purple); }
th[data-dir="asc"]::after { content: " ▲"; }
th[data-dir="desc"]::after { content: " ▼"; }
th.team-col { text-align: left; }
td { padding: 0.6rem 0.4rem; text-align: center; border-radius: 4px; font-weight: 600; font-size: 0.85rem; }
td.team-cell { text-align: left; background: var(--surface); padding-left: 0.75rem; }
.team-badge { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 600; }
.team-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--team-color); border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0; }
.fdr-1 { background: var(--fdr-1); color: #0a4a2c; }
.fdr-2 { background: var(--fdr-2); color: #0a4a2c; }
.fdr-3 { background: var(--fdr-3); color: #1a1a1a; }
.fdr-4 { background: var(--fdr-4); color: #5a1020; }
.fdr-5 { background: var(--fdr-5); color: #ffffff; }
.avg { background: var(--surface); color: var(--text); font-weight: 700; }
tbody tr.highlight td { outline: 2px solid var(--pl-purple); }
tbody tr { cursor: pointer; }
.away { opacity: 0.75; font-weight: 500; font-style: italic; }
@media (max-width: 700px) { td, th { padding: 0.4rem 0.25rem; font-size: 0.75rem; } }
</style>
</head>
<body>
<h1>Fixture Difficulty</h1>
<p class="subtitle">GW14 → GW19 · 6 gameweeks · Generated 2026-05-13</p>
<div class="legend">
Difficulty:
<span class="legend-cell fdr-1">1</span>
<span class="legend-cell fdr-2">2</span>
<span class="legend-cell fdr-3">3</span>
<span class="legend-cell fdr-4">4</span>
<span class="legend-cell fdr-5">5</span>
· Home in bold, away italic · Click column to sort · Click row to highlight
</div>
<table data-sortable>
<thead>
<tr>
<th data-type="text" class="team-col">Team</th>
<th data-type="number">GW14</th>
<th data-type="number">GW15</th>
<th data-type="number">GW16</th>
<th data-type="number">GW17</th>
<th data-type="number">GW18</th>
<th data-type="number">GW19</th>
<th data-type="number">Avg</th>
</tr>
</thead>
<tbody>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#ef0107"><span class="team-dot"></span>ARS</span></td>
<td class="fdr-2" data-sort="2">CHE</td>
<td class="fdr-3" data-sort="3"><span class="away">BHA</span></td>
<td class="fdr-2" data-sort="2">WOL</td>
<td class="fdr-4" data-sort="4"><span class="away">LIV</span></td>
<td class="fdr-2" data-sort="2">CRY</td>
<td class="fdr-3" data-sort="3"><span class="away">FUL</span></td>
<td class="avg" data-sort="2.7">2.7</td>
</tr>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#c8102e"><span class="team-dot"></span>LIV</span></td>
<td class="fdr-2" data-sort="2"><span class="away">WOL</span></td>
<td class="fdr-3" data-sort="3">BRE</td>
<td class="fdr-4" data-sort="4"><span class="away">NEW</span></td>
<td class="fdr-4" data-sort="4">ARS</td>
<td class="fdr-2" data-sort="2"><span class="away">SUN</span></td>
<td class="fdr-3" data-sort="3">EVE</td>
<td class="avg" data-sort="3.0">3.0</td>
</tr>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#6cabdd"><span class="team-dot"></span>MCI</span></td>
<td class="fdr-5" data-sort="5">CHE</td>
<td class="fdr-4" data-sort="4"><span class="away">TOT</span></td>
<td class="fdr-3" data-sort="3">FUL</td>
<td class="fdr-5" data-sort="5"><span class="away">ARS</span></td>
<td class="fdr-4" data-sort="4">NEW</td>
<td class="fdr-3" data-sort="3"><span class="away">BHA</span></td>
<td class="avg" data-sort="4.0">4.0</td>
</tr>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#241f20"><span class="team-dot"></span>NEW</span></td>
<td class="fdr-2" data-sort="2"><span class="away">BUR</span></td>
<td class="fdr-3" data-sort="3">CRY</td>
<td class="fdr-4" data-sort="4">LIV</td>
<td class="fdr-3" data-sort="3"><span class="away">WHU</span></td>
<td class="fdr-4" data-sort="4"><span class="away">MCI</span></td>
<td class="fdr-2" data-sort="2">LEE</td>
<td class="avg" data-sort="3.0">3.0</td>
</tr>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#132257"><span class="team-dot"></span>TOT</span></td>
<td class="fdr-3" data-sort="3"><span class="away">FUL</span></td>
<td class="fdr-3" data-sort="3">MCI</td>
<td class="fdr-2" data-sort="2"><span class="away">BOU</span></td>
<td class="fdr-2" data-sort="2">BUR</td>
<td class="fdr-3" data-sort="3"><span class="away">EVE</span></td>
<td class="fdr-4" data-sort="4">CHE</td>
<td class="avg" data-sort="2.8">2.8</td>
</tr>
<tr>
<td class="team-cell"><span class="team-badge" style="--team-color:#dd0000"><span class="team-dot"></span>NFO</span></td>
<td class="fdr-2" data-sort="2">LEE</td>
<td class="fdr-3" data-sort="3"><span class="away">EVE</span></td>
<td class="fdr-2" data-sort="2">SUN</td>
<td class="fdr-4" data-sort="4"><span class="away">CHE</span></td>
<td class="fdr-2" data-sort="2">BOU</td>
<td class="fdr-3" data-sort="3"><span class="away">BRE</span></td>
<td class="avg" data-sort="2.7">2.7</td>
</tr>
</tbody>
</table>
<script>
document.querySelectorAll('table[data-sortable] th').forEach((th, col) => {
th.addEventListener('click', () => {
const tbody = th.closest('table').tBodies[0];
const rows = [...tbody.rows];
const type = th.dataset.type || 'text';
const dir = th.dataset.dir === 'asc' ? -1 : 1;
th.parentElement.querySelectorAll('th').forEach(h => h.removeAttribute('data-dir'));
th.dataset.dir = dir === 1 ? 'asc' : 'desc';
rows.sort((a, b) => {
const av = a.cells[col].dataset.sort ?? a.cells[col].textContent.trim();
const bv = b.cells[col].dataset.sort ?? b.cells[col].textContent.trim();
return type === 'number' ? (parseFloat(av) - parseFloat(bv)) * dir : av.localeCompare(bv) * dir;
});
rows.forEach(r => tbody.appendChild(r));
});
});
document.querySelectorAll('tbody tr').forEach(tr => {
tr.addEventListener('click', () => tr.classList.toggle('highlight'));
});
</script>
</body>
</html>
<!--
TEMPLATE: gameweek-report.html
The full strategy report — combines squad health, fixtures, captain pick, transfers, and chip strategy.
Replace placeholders with real data sourced from SQL + the user's squad file.
Sections (with jump links): TL;DR, Squad Health, Fixtures, Captain, Transfers, Chip Strategy, Notes.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GW14 Strategy — Very Big Woods</title>
<style>
:root {
--pl-purple: #37003c; --pl-teal: #00ff87; --pl-magenta: #e90052;
--fdr-1: #00ff87; --fdr-2: #91dfb3; --fdr-3: #e8e8e8; --fdr-4: #ff6b85; --fdr-5: #e90052;
--bg: #fafafa; --surface: #ffffff; --text: #1a1a1a; --text-muted: #6b6b6b; --border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --surface: #161616; --text: #f0f0f0; --text-muted: #9b9b9b; --border: #2a2a2a; }
.fdr-3 { background: #4a4a4a !important; color: #f0f0f0 !important; }
}
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif; font-size: 15px; line-height: 1.6; max-width: 900px; margin: 2rem auto; padding: 0 1.25rem; color: var(--text); background: var(--bg); }
h1 { font-family: Georgia, serif; font-weight: 600; letter-spacing: -0.01em; font-size: 2rem; margin-bottom: 0.25rem; }
h2 { font-family: Georgia, serif; font-weight: 600; font-size: 1.35rem; margin-top: 2.5rem; padding-top: 1rem; border-top: 2px solid var(--pl-purple); }
h3 { font-family: Georgia, serif; font-weight: 600; font-size: 1.05rem; margin-top: 1.5rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
nav { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem 1.25rem; margin: 1.5rem 0; font-size: 0.85rem; }
nav a { color: var(--text-muted); text-decoration: none; margin-right: 1.25rem; }
nav a:hover { color: var(--pl-purple); text-decoration: underline; }
.tldr { background: var(--surface); border-left: 4px solid var(--pl-teal); padding: 1.25rem 1.5rem; margin: 1.5rem 0; border-radius: 4px; }
.tldr h3 { margin-top: 0; }
.tldr ul { margin: 0.5rem 0 0; padding-left: 1.25rem; }
.tldr li { margin-bottom: 0.35rem; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; margin: 1rem 0; }
th, td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
th { font-weight: 600; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; background: var(--surface); }
td.num { text-align: right; font-weight: 600; }
.team-badge { display: inline-flex; align-items: center; gap: 0.35rem; }
.team-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--team-color); border: 1px solid rgba(0,0,0,0.15); }
.fix-chip { display: inline-block; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.75rem; font-weight: 600; margin-right: 2px; }
.fdr-1 { background: var(--fdr-1); color: #0a4a2c; }
.fdr-2 { background: var(--fdr-2); color: #0a4a2c; }
.fdr-3 { background: var(--fdr-3); color: #1a1a1a; }
.fdr-4 { background: var(--fdr-4); color: #5a1020; }
.fdr-5 { background: var(--fdr-5); color: #ffffff; }
.away { opacity: 0.75; font-style: italic; }
.status { display: inline-block; padding: 0 0.35rem; border-radius: 3px; font-size: 0.7rem; font-weight: 700; margin-right: 0.35rem; }
.status-d { background: #ffd54f; color: #5a3d00; }
.status-i { background: var(--fdr-5); color: white; }
.status-s { background: #1a1a1a; color: white; }
.pick { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--surface); border-radius: 6px; border-left: 4px solid var(--pl-teal); margin: 0.75rem 0; }
.pick.alt { border-left-color: var(--text-muted); }
.pick .name { font-weight: 600; font-size: 1.05rem; }
.pick .reason { color: var(--text-muted); font-size: 0.9rem; margin-top: 0.2rem; }
.pick .xpts { margin-left: auto; font-size: 1.25rem; font-weight: 700; color: var(--pl-purple); }
.verdict-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1.25rem 1.5rem; margin: 1rem 0; }
.verdict-card .head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.verdict-card .title { font-family: Georgia, serif; font-weight: 600; font-size: 1.05rem; }
.verdict { padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.verdict-yes { background: var(--fdr-1); color: #0a4a2c; }
.verdict-no { background: var(--fdr-5); color: white; }
.verdict-maybe { background: #ffd54f; color: #5a3d00; }
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.75rem 0; }
.chip { padding: 0.3rem 0.7rem; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; font-size: 0.8rem; }
.chip.used { opacity: 0.4; text-decoration: line-through; }
.chip.recommend { background: var(--pl-teal); color: #0a4a2c; border-color: var(--pl-teal); font-weight: 600; }
details summary { cursor: pointer; font-weight: 600; color: var(--pl-purple); margin: 0.5rem 0; }
.notes { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 1rem 1.25rem; margin-top: 1rem; color: var(--text-muted); font-size: 0.9rem; }
</style>
</head>
<body>
<h1>GW14 Strategy</h1>
<p class="subtitle">Very Big Woods · Generated 2026-05-13 · Deadline Sat 11:30</p>
<nav>
<a href="#tldr">TL;DR</a>
<a href="#health">Squad Health</a>
<a href="#fixtures">Fixtures</a>
<a href="#captain">Captain</a>
<a href="#transfers">Transfers</a>
<a href="#chips">Chips</a>
</nav>
<div class="tldr" id="tldr">
<h3>TL;DR</h3>
<ul>
<li><strong>Captain Salah.</strong> Wolves (A) is the best non-Haaland fixture in the GW; Salah's form clears 8.0 ppg.</li>
<li><strong>Make one transfer.</strong> Maddison → Palmer. +16.6 xPts over the next 5 GWs for £3.1m.</li>
<li><strong>Hold Haaland for now.</strong> CHE (A) is rough, but Spurs (H) and Fulham follow — sell only if you need budget.</li>
<li><strong>No chip this week.</strong> Wildcard candidate: GW18, after the international break and price drift settles.</li>
</ul>
</div>
<h2 id="health">Squad Health</h2>
<table>
<thead><tr><th>Player</th><th>Status</th><th>Note</th></tr></thead>
<tbody>
<tr><td><span class="team-badge" style="--team-color:#003399"><span class="team-dot"></span></span> McNeil</td><td><span class="status status-i">I</span> Injured</td><td>Hamstring — out until GW16. Bench priority 4 is fine; consider replacing if it stretches.</td></tr>
<tr><td><span class="team-badge" style="--team-color:#95bfe5"><span class="team-dot"></span></span> Cash</td><td><span class="status status-d">D</span> Doubtful</td><td>75% chance to play per Emery presser. Acceptable bench risk.</td></tr>
<tr><td colspan="3" style="color:var(--text-muted); text-align:center; padding:0.75rem;">9 starters fit and starting · 1 doubtful · 1 injured</td></tr>
</tbody>
</table>
<h2 id="fixtures">Fixtures — Next 5 GWs</h2>
<p style="color:var(--text-muted); font-size:0.9rem;">Owned-team fixtures only. Full league matrix: see <code>fixture-matrix.html</code>.</p>
<table>
<thead><tr><th>Team</th><th>GW14</th><th>GW15</th><th>GW16</th><th>GW17</th><th>GW18</th></tr></thead>
<tbody>
<tr>
<td><span class="team-badge" style="--team-color:#c8102e"><span class="team-dot"></span>LIV</span></td>
<td><span class="fix-chip fdr-2 away">WOL</span></td>
<td><span class="fix-chip fdr-3">BRE</span></td>
<td><span class="fix-chip fdr-4 away">NEW</span></td>
<td><span class="fix-chip fdr-4">ARS</span></td>
<td><span class="fix-chip fdr-2 away">SUN</span></td>
</tr>
<tr>
<td><span class="team-badge" style="--team-color:#ef0107"><span class="team-dot"></span>ARS</span></td>
<td><span class="fix-chip fdr-2">CHE</span></td>
<td><span class="fix-chip fdr-3 away">BHA</span></td>
<td><span class="fix-chip fdr-2">WOL</span></td>
<td><span class="fix-chip fdr-4 away">LIV</span></td>
<td><span class="fix-chip fdr-2">CRY</span></td>
</tr>
<tr>
<td><span class="team-badge" style="--team-color:#6cabdd"><span class="team-dot"></span>MCI</span></td>
<td><span class="fix-chip fdr-5">CHE</span></td>
<td><span class="fix-chip fdr-4 away">TOT</span></td>
<td><span class="fix-chip fdr-3">FUL</span></td>
<td><span class="fix-chip fdr-5 away">ARS</span></td>
<td><span class="fix-chip fdr-4">NEW</span></td>
</tr>
<tr>
<td><span class="team-badge" style="--team-color:#dd0000"><span class="team-dot"></span>NFO</span></td>
<td><span class="fix-chip fdr-2">LEE</span></td>
<td><span class="fix-chip fdr-3 away">EVE</span></td>
<td><span class="fix-chip fdr-2">SUN</span></td>
<td><span class="fix-chip fdr-4 away">CHE</span></td>
<td><span class="fix-chip fdr-2">BOU</span></td>
</tr>
<tr>
<td><span class="team-badge" style="--team-color:#241f20"><span class="team-dot"></span>NEW</span></td>
<td><span class="fix-chip fdr-2 away">BUR</span></td>
<td><span class="fix-chip fdr-3">CRY</span></td>
<td><span class="fix-chip fdr-4">LIV</span></td>
<td><span class="fix-chip fdr-3 away">WHU</span></td>
<td><span class="fix-chip fdr-4 away">MCI</span></td>
</tr>
</tbody>
</table>
<h2 id="captain">Captain</h2>
<div class="pick">
<div>
<div class="name">Salah · vs WOL (A)</div>
<div class="reason">Wolves have conceded 9 in their last 6. Salah's last-5 form is 8.0 ppg with three returns. Ownership 38% caps downside.</div>
</div>
<div class="xpts">8.6</div>
</div>
<div class="pick alt">
<div>
<div class="name">Palmer · vs MCI (H)</div>
<div class="reason">Differential at 31% if you transferred him in. Set-piece + penalty role gives a floor most premium MIDs lack.</div>
</div>
<div class="xpts">8.1</div>
</div>
<details>
<summary>See full top-8 ranking</summary>
<p>For the complete table with sortable form/xPts/ownership columns, generate <code>captain-ranking.html</code>.</p>
</details>
<h2 id="transfers">Transfers</h2>
<div class="verdict-card">
<div class="head">
<span class="title">Maddison → Palmer</span>
<span class="verdict verdict-yes">Recommended · 0 hits</span>
</div>
<p>+16.6 xPts over the next 5 GWs for £3.1m. Free transfer covers it; £5.2m leftover in the bank.</p>
</div>
<div class="verdict-card">
<div class="head">
<span class="title">Haaland → Wood (with downgrade)</span>
<span class="verdict verdict-maybe">Hold this week</span>
</div>
<p>Wood's form (7.4) and fixtures (avg FDR 2.6) beat Haaland's, but Haaland's ceiling and ownership floor are too high to drop into one bad fixture. Reassess after CHE (A).</p>
</div>
<h2 id="chips">Chip Strategy</h2>
<div class="chips">
<span class="chip used">Wildcard 1</span>
<span class="chip">Wildcard 2</span>
<span class="chip">Free Hit</span>
<span class="chip">Bench Boost</span>
<span class="chip">Triple Captain</span>
</div>
<p><strong>Wildcard 2:</strong> Target GW18, after international-break price drift. Current squad is fixture-aligned through GW17.</p>
<p><strong>Triple Captain:</strong> Hold for a Salah or Haaland double gameweek — projection has DGW32 as the likely candidate.</p>
<p><strong>Bench Boost:</strong> Pair with WC2 — wildcard the bench up to bench-boosters in the same window.</p>
<div class="notes">
<strong>Notes from previous analysis:</strong> Last GW's bench was 11 pts left on the bench (Cunha hat-trick); consider promoting a Wolves or Palace bench option after WC2 to reduce bench variance.
</div>
</body>
</html>
<!--
TEMPLATE: squad-view.html
Replace placeholder data with real squad from ~/.fplcopilot/squads/{name}.md and current SQL data.
Slots: {squad_name}, {gw_num}, {gw_points}, {squad_value}, {bank}, {free_transfers}, {chip_active}
starters[] in formation order (GK, DEF[], MID[], FWD[]) and bench[] (GK, then outfield in priority).
Per player: team_color (hex), team_code (3-letter), web_name, position, price_m, form, points_gw, status (a/d/i/s), captain (c/v/null), team_id.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Squad — Very Big Woods · GW14</title>
<style>
:root {
--pl-purple: #37003c; --pl-teal: #00ff87; --pl-magenta: #e90052;
--fdr-1: #00ff87; --fdr-2: #91dfb3; --fdr-3: #e8e8e8; --fdr-4: #ff6b85; --fdr-5: #e90052;
--bg: #fafafa; --surface: #ffffff; --text: #1a1a1a; --text-muted: #6b6b6b; --border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --surface: #161616; --text: #f0f0f0; --text-muted: #9b9b9b; --border: #2a2a2a; }
}
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif; font-size: 15px; line-height: 1.55; max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem; color: var(--text); background: var(--bg); }
h1 { font-family: Georgia, "Times New Roman", serif; font-weight: 600; letter-spacing: -0.01em; font-size: 1.75rem; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
.meta { display: flex; flex-wrap: wrap; gap: 1.5rem; margin: 1.5rem 0; padding: 1rem 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
.meta-item { display: flex; flex-direction: column; }
.meta-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.meta-value { font-size: 1.15rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.pitch { background: linear-gradient(180deg, #1a5d1a 0%, #2d7a2d 100%); padding: 1.5rem 0.75rem; border-radius: 8px; margin: 1.5rem 0; }
.row { display: flex; justify-content: center; gap: 0.75rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.row:last-child { margin-bottom: 0; }
.player { background: var(--surface); border-radius: 6px; padding: 0.5rem 0.6rem; min-width: 90px; text-align: center; font-size: 0.8rem; position: relative; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
.player .team-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.15); margin-right: 0.3rem; vertical-align: middle; }
.player .name { font-weight: 600; color: var(--text); margin: 0.15rem 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player .pts { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--pl-purple); line-height: 1; margin: 0.2rem 0; }
.player .price { color: var(--text-muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
.player .badge { position: absolute; top: -6px; right: -6px; background: var(--pl-purple); color: white; width: 18px; height: 18px; border-radius: 50%; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.player.cap .badge { background: var(--pl-magenta); }
.player.vc .badge { background: var(--text-muted); }
.status { position: absolute; top: -6px; left: -6px; padding: 0 0.35rem; border-radius: 3px; font-size: 0.65rem; font-weight: 700; }
.status-d { background: #ffd54f; color: #5a3d00; }
.status-i { background: var(--fdr-5); color: white; }
.status-s { background: #1a1a1a; color: white; }
.bench { background: var(--surface); border: 1px solid var(--border); padding: 1rem 0.75rem; border-radius: 8px; }
.bench h2 { font-family: Georgia, serif; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin: 0 0 0.75rem; text-align: center; }
.bench .row { margin-bottom: 0; }
.bench .player { background: var(--bg); border: 1px solid var(--border); box-shadow: none; }
@media (max-width: 600px) { .player { min-width: 75px; padding: 0.4rem; font-size: 0.75rem; } }
</style>
</head>
<body>
<h1>Very Big Woods</h1>
<p class="subtitle">Squad view · GW14 · Generated 2026-05-13</p>
<div class="meta">
<div class="meta-item"><span class="meta-label">GW points</span><span class="meta-value">62</span></div>
<div class="meta-item"><span class="meta-label">Squad value</span><span class="meta-value">£103.4m</span></div>
<div class="meta-item"><span class="meta-label">Bank</span><span class="meta-value">£0.6m</span></div>
<div class="meta-item"><span class="meta-label">Free transfers</span><span class="meta-value">1</span></div>
<div class="meta-item"><span class="meta-label">Chip</span><span class="meta-value">—</span></div>
</div>
<div class="pitch">
<div class="row">
<div class="player">
<span class="team-dot" style="background:#ef0107"></span>ARS
<div class="name">Raya</div>
<div class="pts">6</div>
<div class="price">£5.5m</div>
</div>
</div>
<div class="row">
<div class="player"><span class="team-dot" style="background:#c8102e"></span>LIV<div class="name">Robertson</div><div class="pts">8</div><div class="price">£6.7m</div></div>
<div class="player"><span class="team-dot" style="background:#0057b8"></span>BHA<div class="name">Estupiñán</div><div class="pts">5</div><div class="price">£5.4m</div></div>
<div class="player"><span class="team-dot" style="background:#ef0107"></span>ARS<div class="name">Saliba</div><div class="pts">7</div><div class="price">£6.1m</div></div>
<div class="player cap"><span class="team-dot" style="background:#6cabdd"></span>MCI<div class="name">Gvardiol</div><div class="pts">9</div><div class="price">£6.0m</div></div>
</div>
<div class="row">
<div class="player cap"><span class="team-dot" style="background:#c8102e"></span>LIV<div class="name">Salah</div><div class="pts">14</div><div class="price">£13.2m</div><span class="badge">C</span></div>
<div class="player vc"><span class="team-dot" style="background:#ef0107"></span>ARS<div class="name">Saka</div><div class="pts">8</div><div class="price">£10.1m</div><span class="badge">V</span></div>
<div class="player"><span class="team-dot" style="background:#132257"></span>TOT<div class="name">Maddison</div><div class="pts">5</div><div class="price">£7.6m</div></div>
</div>
<div class="row">
<div class="player"><span class="team-dot" style="background:#6cabdd"></span>MCI<div class="name">Haaland</div><div class="pts">2</div><div class="price">£15.1m</div></div>
<div class="player"><span class="team-dot" style="background:#dd0000"></span>NFO<div class="name">Wood</div><div class="pts">10</div><div class="price">£6.8m</div></div>
<div class="player"><span class="team-dot" style="background:#241f20"></span>NEW<div class="name">Isak</div><div class="pts">8</div><div class="price">£8.4m</div></div>
</div>
</div>
<div class="bench">
<h2>Bench</h2>
<div class="row">
<div class="player"><span class="team-dot" style="background:#7a263a"></span>WHU<div class="name">Areola</div><div class="pts">3</div><div class="price">£4.4m</div></div>
<div class="player"><span class="status status-d" title="Doubtful">D</span><span class="team-dot" style="background:#95bfe5"></span>AVL<div class="name">Cash</div><div class="pts">2</div><div class="price">£4.7m</div></div>
<div class="player"><span class="team-dot" style="background:#fdb913"></span>WOL<div class="name">Cunha</div><div class="pts">1</div><div class="price">£6.6m</div></div>
<div class="player"><span class="status status-i" title="Injured">I</span><span class="team-dot" style="background:#003399"></span>EVE<div class="name">McNeil</div><div class="pts">0</div><div class="price">£5.2m</div></div>
</div>
</div>
</body>
</html>
<!--
TEMPLATE: transfer-comparison.html
Replace placeholder data with real transfer scenarios.
Slots: {transfers[]} — array of {out: player, in: player, delta: {price, points_last5, xpts_next5, fdr_avg}, verdict, notes}.
Per player: team_color, team_code, web_name, position, price_m, form, points_last5, xpts_next5, next5_fixtures (array of {opp, was_home, fdr}).
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Transfer Comparison — GW14</title>
<style>
:root {
--pl-purple: #37003c; --pl-teal: #00ff87; --pl-magenta: #e90052;
--fdr-1: #00ff87; --fdr-2: #91dfb3; --fdr-3: #e8e8e8; --fdr-4: #ff6b85; --fdr-5: #e90052;
--bg: #fafafa; --surface: #ffffff; --text: #1a1a1a; --text-muted: #6b6b6b; --border: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --surface: #161616; --text: #f0f0f0; --text-muted: #9b9b9b; --border: #2a2a2a; }
.fdr-3 { background: #4a4a4a !important; color: #f0f0f0 !important; }
}
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, -apple-system, "Segoe UI", sans-serif; font-size: 15px; line-height: 1.55; max-width: 1100px; margin: 2rem auto; padding: 0 1.25rem; color: var(--text); background: var(--bg); }
h1 { font-family: Georgia, serif; font-weight: 600; letter-spacing: -0.01em; font-size: 1.75rem; margin-bottom: 0.25rem; }
h2 { font-family: Georgia, serif; font-size: 1.1rem; margin-top: 2rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--text-muted); font-size: 0.9rem; margin-top: 0; }
.scenario { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin: 1.5rem 0; overflow: hidden; }
.scenario-header { padding: 0.75rem 1.25rem; background: var(--bg); border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
.scenario-title { font-family: Georgia, serif; font-weight: 600; font-size: 1rem; }
.verdict { padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.verdict-yes { background: var(--fdr-1); color: #0a4a2c; }
.verdict-no { background: var(--fdr-5); color: white; }
.verdict-maybe { background: #ffd54f; color: #5a3d00; }
.pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: 1rem; padding: 1.25rem; align-items: stretch; }
.player-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; }
.player-card .label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 600; }
.player-card.out .label { color: var(--pl-magenta); }
.player-card.in .label { color: #0a8a4a; }
.player-name { font-size: 1.15rem; font-weight: 600; margin: 0.25rem 0 0.15rem; }
.player-team { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; color: var(--text-muted); font-weight: 500; margin-bottom: 0.75rem; }
.team-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--team-color); border: 1px solid rgba(0,0,0,0.15); }
.stat-row { display: flex; justify-content: space-between; padding: 0.35rem 0; border-top: 1px solid var(--border); font-size: 0.85rem; }
.stat-row .k { color: var(--text-muted); }
.stat-row .v { font-weight: 600; font-variant-numeric: tabular-nums; }
.fixtures-mini { display: flex; gap: 2px; margin-top: 0.75rem; }
.fix-cell { flex: 1; padding: 0.35rem 0.2rem; border-radius: 3px; text-align: center; font-size: 0.7rem; font-weight: 600; }
.arrow { display: flex; align-items: center; justify-content: center; color: var(--pl-purple); font-size: 1.5rem; font-weight: 700; }
.deltas { background: var(--bg); padding: 1rem 1.25rem; border-top: 1px solid var(--border); display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; }
.delta { display: flex; flex-direction: column; }
.delta .k { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.delta .v { font-size: 1.1rem; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 0.15rem; }
.delta.pos .v { color: #0a8a4a; }
.delta.neg .v { color: var(--pl-magenta); }
.notes { padding: 0.75rem 1.25rem; font-size: 0.9rem; color: var(--text-muted); border-top: 1px solid var(--border); background: var(--bg); }
.fdr-1 { background: var(--fdr-1); color: #0a4a2c; }
.fdr-2 { background: var(--fdr-2); color: #0a4a2c; }
.fdr-3 { background: var(--fdr-3); color: #1a1a1a; }
.fdr-4 { background: var(--fdr-4); color: #5a1020; }
.fdr-5 { background: var(--fdr-5); color: #ffffff; }
@media (max-width: 600px) { .pair { grid-template-columns: 1fr; } .arrow { transform: rotate(90deg); padding: 0.5rem 0; } }
</style>
</head>
<body>
<h1>Transfer Comparison</h1>
<p class="subtitle">GW14 · 1 free transfer · 0 hits to confirm</p>
<div class="scenario">
<div class="scenario-header">
<span class="scenario-title">Scenario 1 — Sell Haaland, buy Wood</span>
<span class="verdict verdict-maybe">Marginal</span>
</div>
<div class="pair">
<div class="player-card out">
<div class="label">Out</div>
<div class="player-name">Haaland</div>
<div class="player-team"><span class="team-dot" style="--team-color:#6cabdd"></span>MCI · FWD</div>
<div class="stat-row"><span class="k">Price</span><span class="v">£15.1m</span></div>
<div class="stat-row"><span class="k">Form</span><span class="v">4.6</span></div>
<div class="stat-row"><span class="k">Pts last 5</span><span class="v">23</span></div>
<div class="stat-row"><span class="k">xPts next 5</span><span class="v">32.4</span></div>
<div class="fixtures-mini">
<div class="fix-cell fdr-5">CHE</div>
<div class="fix-cell fdr-4">TOT</div>
<div class="fix-cell fdr-3">FUL</div>
<div class="fix-cell fdr-5">ARS</div>
<div class="fix-cell fdr-4">NEW</div>
</div>
</div>
<div class="arrow">→</div>
<div class="player-card in">
<div class="label">In</div>
<div class="player-name">Wood</div>
<div class="player-team"><span class="team-dot" style="--team-color:#dd0000"></span>NFO · FWD</div>
<div class="stat-row"><span class="k">Price</span><span class="v">£6.8m</span></div>
<div class="stat-row"><span class="k">Form</span><span class="v">7.4</span></div>
<div class="stat-row"><span class="k">Pts last 5</span><span class="v">37</span></div>
<div class="stat-row"><span class="k">xPts next 5</span><span class="v">28.1</span></div>
<div class="fixtures-mini">
<div class="fix-cell fdr-2">LEE</div>
<div class="fix-cell fdr-3">EVE</div>
<div class="fix-cell fdr-2">SUN</div>
<div class="fix-cell fdr-4">CHE</div>
<div class="fix-cell fdr-2">BOU</div>
</div>
</div>
</div>
<div class="deltas">
<div class="delta pos"><span class="k">Price freed</span><span class="v">+£8.3m</span></div>
<div class="delta pos"><span class="k">Pts last 5</span><span class="v">+14</span></div>
<div class="delta neg"><span class="k">xPts next 5</span><span class="v">−4.3</span></div>
<div class="delta pos"><span class="k">Avg FDR</span><span class="v">−1.4</span></div>
</div>
<div class="notes">
Wood beats Haaland on form and fixtures, but loses on raw ceiling and ownership floor. Frees £8.3m that could upgrade a midfielder. Verdict hinges on what you do with the freed budget — see Scenario 2 below.
</div>
</div>
<div class="scenario">
<div class="scenario-header">
<span class="scenario-title">Scenario 2 — Sell Maddison, buy Palmer (requires Scenario 1's freed budget)</span>
<span class="verdict verdict-yes">Recommended</span>
</div>
<div class="pair">
<div class="player-card out">
<div class="label">Out</div>
<div class="player-name">Maddison</div>
<div class="player-team"><span class="team-dot" style="--team-color:#132257"></span>TOT · MID</div>
<div class="stat-row"><span class="k">Price</span><span class="v">£7.6m</span></div>
<div class="stat-row"><span class="k">Form</span><span class="v">3.8</span></div>
<div class="stat-row"><span class="k">Pts last 5</span><span class="v">19</span></div>
<div class="stat-row"><span class="k">xPts next 5</span><span class="v">22.0</span></div>
<div class="fixtures-mini">
<div class="fix-cell fdr-3">FUL</div>
<div class="fix-cell fdr-3">MCI</div>
<div class="fix-cell fdr-2">BOU</div>
<div class="fix-cell fdr-2">BUR</div>
<div class="fix-cell fdr-3">EVE</div>
</div>
</div>
<div class="arrow">→</div>
<div class="player-card in">
<div class="label">In</div>
<div class="player-name">Palmer</div>
<div class="player-team"><span class="team-dot" style="--team-color:#034694"></span>CHE · MID</div>
<div class="stat-row"><span class="k">Price</span><span class="v">£10.7m</span></div>
<div class="stat-row"><span class="k">Form</span><span class="v">8.2</span></div>
<div class="stat-row"><span class="k">Pts last 5</span><span class="v">41</span></div>
<div class="stat-row"><span class="k">xPts next 5</span><span class="v">38.6</span></div>
<div class="fixtures-mini">
<div class="fix-cell fdr-3">MCI</div>
<div class="fix-cell fdr-4">ARS</div>
<div class="fix-cell fdr-2">BHA</div>
<div class="fix-cell fdr-3">NFO</div>
<div class="fix-cell fdr-3">TOT</div>
</div>
</div>
</div>
<div class="deltas">
<div class="delta neg"><span class="k">Price cost</span><span class="v">−£3.1m</span></div>
<div class="delta pos"><span class="k">Pts last 5</span><span class="v">+22</span></div>
<div class="delta pos"><span class="k">xPts next 5</span><span class="v">+16.6</span></div>
<div class="delta"><span class="k">Avg FDR</span><span class="v">±0</span></div>
</div>
<div class="notes">
Big xPts upgrade for £3.1m of the £8.3m freed in Scenario 1. Leaves £5.2m in the bank for a future move. Captain-viability for Palmer this run is a bonus.
</div>
</div>
</body>
</html>