diff --git a/src/dashboard.html b/src/dashboard.html index 495e8f9..b236c81 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -244,6 +244,40 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; .event-status.on { color: #3fb950; } .event-status.off { color: #8b949e; } +/* ── Formation Pitch ── */ +.pitch-wrap { position:relative; width:100%; max-width:380px; aspect-ratio:3/4.2; background:#16502e; border-radius:10px; overflow:hidden; border:1px solid #30363d; } +.pitch-markings { position:absolute; inset:0; pointer-events:none; } +.pitch-centerline { position:absolute; left:8%; right:8%; top:50%; height:1px; background:rgba(255,255,255,0.18); } +.pitch-center-circle { position:absolute; left:50%; top:50%; width:18%; aspect-ratio:1; border-radius:50%; border:1px solid rgba(255,255,255,0.18); transform:translate(-50%,-50%); } +.pitch-penalty-top { position:absolute; left:27%; right:27%; top:3%; height:18%; border:1px solid rgba(255,255,255,0.18); border-bottom:none; } +.pitch-penalty-bot { position:absolute; left:27%; right:27%; bottom:3%; height:18%; border:1px solid rgba(255,255,255,0.18); border-top:none; } +.pitch-border { position:absolute; inset:3%; border:1px solid rgba(255,255,255,0.18); } +.pitch-player { position:absolute; transform:translate(-50%,-50%); text-align:center; z-index:1; } +.pitch-chip { background:rgba(22,27,34,0.92); border:1px solid #30363d; border-radius:5px; padding:3px 5px; min-width:48px; } +.pitch-chip-ovr { font-size:0.82rem; font-weight:800; color:#f5a623; line-height:1.1; } +.pitch-chip-name { font-size:0.55rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:56px; color:#c9d1d9; } +.pitch-chip-pos { font-size:0.5rem; color:#8b949e; text-transform:uppercase; } +.pitch-chip.empty-chip { opacity:0.4; border-style:dashed; } +.pitch-toggle { display:flex; gap:6px; margin-bottom:10px; } +.pitch-toggle button { padding:4px 12px; font-size:0.78rem; border-radius:12px; border:1px solid #30363d; background:#21262d; color:#8b949e; cursor:pointer; } +.pitch-toggle button.active { background:#f5a623; color:#000; border-color:#f5a623; } + +/* ── Season Zone Bar ── */ +.season-zone-bar { position:relative; height:28px; border-radius:4px; overflow:hidden; margin:12px 0; background:#21262d; } +.szb-relegate { position:absolute; left:0; height:100%; background:#7f1d1d; } +.szb-safe { position:absolute; height:100%; background:#21262d; } +.szb-promote { position:absolute; right:0; height:100%; background:#14532d; } +.szb-marker { position:absolute; top:0; bottom:0; width:3px; background:#f5a623; transform:translateX(-50%); border-radius:2px; } +.szb-label { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:0.72rem; font-weight:700; color:rgba(255,255,255,0.7); } +.szb-zone-label { position:absolute; font-size:0.6rem; color:rgba(255,255,255,0.5); top:50%; transform:translateY(-50%); } + +/* ── Season History ── */ +.season-hist-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #21262d; font-size:0.82rem; } +.hist-result-badge { width:24px; height:24px; border-radius:4px; font-weight:800; font-size:0.7rem; display:flex; align-items:center; justify-content:center; flex-shrink:0; } +.hist-result-promoted { background:#14532d; color:#4ade80; } +.hist-result-maintained { background:#374151; color:#d1d5db; } +.hist-result-relegated { background:#7f1d1d; color:#f87171; } + /* ── Card Detail Modal ── */ .cd-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.82); z-index:300; align-items:center; justify-content:center; } .cd-overlay.open { display:flex; } @@ -456,6 +490,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;

Season Progress

Loading…
+
+

Season History

+
Loading…
+
@@ -628,10 +666,15 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
+
+
+ + +
-
Loading…
+

Chemistry

@@ -1161,34 +1204,93 @@ async function loadDivision() { try { const d = await api('GET', '/division'); const rec = d.record || {}; - const ptsMax = 30; // 10 matches × 3 pts - const ptsPct = Math.min(100, Math.round((d.season_points / ptsMax) * 100)); - const promoPct = d.pts_for_promotion > 0 - ? Math.min(100, Math.round((d.season_points / d.pts_for_promotion) * 100)) : 100; + const promoThresh = d.promotion_pts ?? 21; + const relThresh = d.relegation_pts ?? 3; + const seasonLen = d.season_length ?? 10; + const ptsMax = seasonLen * 3; + const pts = d.season_points ?? 0; + const divLabel = d.division === 1 ? 'Top Flight' : d.division === 10 ? 'Amateur' : `Division ${d.division}`; + + // Zone bar: total bar spans 0–ptsMax; rel zone 0–relThresh, promo zone promoThresh–ptsMax + const relPct = Math.round((relThresh / ptsMax) * 100); + const promoPct = Math.round((promoThresh / ptsMax) * 100); + const safePct = promoPct - relPct; + const markerPct = Math.min(100, Math.round((pts / ptsMax) * 100)); + + const zoneBar = ` +
+ Relegation zone ≤${relThresh}pts + ${pts} pts + Promotion ≥${promoThresh}pts +
+
+
+
+
+
+
${pts} / ${ptsMax}
+
+
+ Rel + Safe + Prom + ▲ You +
`; + + const zoneTxt = pts >= promoThresh && d.can_be_relegated !== false + ? 'Promotion zone ↑' + : pts <= relThresh && d.can_be_relegated + ? 'Relegation zone ↓' + : 'Safe zone'; el('division-card').innerHTML = `
${d.division}
-
Division · Season ${d.season_number}
+
${divLabel} · Season ${d.season_number}
${rec.wins ?? 0}
W
${rec.draws ?? 0}
D
${rec.losses ?? 0}
L
+
${zoneTxt}
`; el('season-progress').innerHTML = ` -
Points${d.season_points}
-
For promotion${d.pts_for_promotion} pts needed
-
-
-
-
Matches played${d.matches_played} / 10
-
Remaining${d.matches_remaining}
+ ${zoneBar} +
Matches played${d.matches_played} / ${seasonLen}
+
Remaining${d.matches_remaining} matches
Started${d.started_at ? new Date(d.started_at).toLocaleDateString() : '—'}
+ ${d.promotion_achievable + ? `
Promotion still achievable!
` + : d.can_be_relegated && pts > relThresh + ? `
Promotion out of reach — finish strong.
` + : ''} `; } catch (e) { el('division-card').innerHTML = `
${e.message}
`; el('season-progress').innerHTML = ''; } + + try { + const h = await api('GET', '/division/history'); + const history = h.history || []; + if (!history.length) { + el('season-history').innerHTML = 'No completed seasons yet.'; + } else { + el('season-history').innerHTML = history.map(s => { + const badgeCls = `hist-result-${s.result}`; + const letter = s.result === 'promoted' ? '↑' : s.result === 'relegated' ? '↓' : '='; + const divChg = s.result !== 'maintained' + ? ` → Div ${s.new_division}` : ''; + return `
+
${letter}
+
+
Season ${s.season_number} · Div ${s.division}${divChg}
+
${s.wins}W ${s.draws}D ${s.losses}L · ${s.season_points}pts · +${s.coins_awarded.toLocaleString()}c
+
+
${new Date(s.ended_at).toLocaleDateString()}
+
`; + }).join(''); + } + } catch (_) { el('season-history').innerHTML = 'History unavailable.'; } } async function claimRivalsReward() { @@ -1744,6 +1846,107 @@ async function saveSettings() { // ── Squad ───────────────────────────────────────────────────────────────────── +// ── Formation Pitch View ───────────────────────────────────────────────────── + +let _squadPitchData = null; // cache last squad for pitch toggle + +const POS_GROUP = { + GK: 0, + SW: 1, CB: 1, RB: 1, LB: 1, RWB: 1, LWB: 1, WB: 1, + DM: 2, CDM: 2, CM: 2, RM: 2, LM: 2, + AM: 3, CAM: 3, RAM: 3, LAM: 3, CF: 3, + RW: 4, LW: 4, RF: 4, LF: 4, ST: 4, SS: 4, FW: 4, +}; + +function pitchPosGroup(posLabel) { + if (!posLabel) return 2; + const up = posLabel.toUpperCase().trim(); + return POS_GROUP[up] ?? (up.startsWith('G') ? 0 : up.includes('B') || up === 'SW' ? 1 : up.startsWith('S') || up.startsWith('F') ? 4 : 2); +} + +function renderPitch(players, formation, playerChems) { + const starters = players.filter(p => !p.is_on_bench); + const bench = players.filter(p => p.is_on_bench); + const frmParts = (formation || '4-4-2').split('-').map(Number); + + // Build lines: line 0 = GK, then one line per formation group + const lines = [[], ...frmParts.map(() => [])]; + starters.forEach(p => { + const g = pitchPosGroup(p.position_label || p.card?.position); + // Map role group (0=GK,1=DEF,2=MID,3=CAM,4=ATT) → line index + // GK → 0; otherwise distribute across lines 1..frmParts.length + if (g === 0) { lines[0].push(p); return; } + // For 2-group formations map to nearest line; for 3+ map linearly + const mapped = Math.min(Math.round((g / 4) * (frmParts.length - 1)) + 1, frmParts.length); + lines[mapped].push(p); + }); + + const totalLines = lines.length; + const pitchMarkings = ` +
+
+
+
+
+
+
`; + + const chips = []; + lines.forEach((linePlayers, lineIdx) => { + if (!linePlayers.length) return; + // y: GK at 90%, top line at 10% from top + const yPct = 90 - (lineIdx / (totalLines - 1)) * 80; + linePlayers.forEach((p, i) => { + const xPct = linePlayers.length === 1 ? 50 : 8 + (i / (linePlayers.length - 1)) * 84; + const card = p.card; + const ovr = card?.overall ?? '?'; + const name = card?.name ? card.name.split(' ').pop() : '?'; + const pos = p.position_label || card?.position || '?'; + const pChem = (playerChems || {})[p.owned_card_id] ?? 0; + const chemDot = pChem >= 7 ? '#3fb950' : pChem >= 4 ? '#d29922' : '#f85149'; + chips.push(`
+
+
+
${ovr}
+
${name}
+
${pos}
+
+
`); + }); + }); + + let benchHtml = ''; + if (bench.length) { + benchHtml = `
+
Bench
+
+ ${bench.map(p => { + const card = p.card; + return `
+
${card?.overall ?? '?'}
+
${p.position_label || card?.position || '?'}
+
`; + }).join('')} +
+
`; + } + + return `
${pitchMarkings}${chips.join('')}
${benchHtml}`; +} + +let _sqDisplayMode = 'grid'; +function setSquadDisplayMode(mode) { + _sqDisplayMode = mode; + el('sq-grid-btn').className = mode === 'grid' ? 'active' : ''; + el('sq-pitch-btn').className = mode === 'pitch' ? 'active' : ''; + el('squad-grid').style.display = mode === 'grid' ? '' : 'none'; + el('squad-pitch').style.display = mode === 'pitch' ? '' : 'none'; + if (mode === 'pitch' && _squadPitchData) { + const {players, formation, chems} = _squadPitchData; + el('squad-pitch').innerHTML = renderPitch(players, formation, chems); + } +} + async function loadSquad() { try { const data = await api('GET', '/squad'); @@ -1757,8 +1960,15 @@ async function loadSquad() { ${squad?.formation || '—'}
`; + // Cache for pitch toggle + _squadPitchData = { players, formation: squad?.formation || '4-4-2', chems: chem.players || {} }; + if (_sqDisplayMode === 'pitch') { + el('squad-pitch').innerHTML = renderPitch(players, squad?.formation, chem.players || {}); + } + if (!players.length) { el('squad-grid').innerHTML = 'No squad saved yet. Use /squad POST from the game or API.'; + el('squad-pitch').innerHTML = 'No squad saved yet.'; el('squad-chemistry').innerHTML = ''; return; } diff --git a/src/mapper.rs b/src/mapper.rs index 51298d2..677ada3 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -237,6 +237,13 @@ const EXACT: &[ExactRoute] = &[ notes: "FUT notifications (plural form) → Core notifications", }, // ── Squad list ──────────────────────────────────────────────────────────── + // ── Division / Season history ───────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/division/history", + core_method: "GET", core_path: "/division/history", + notes: "FUT division history → Core season history", + }, + // ── Squad list ──────────────────────────────────────────────────────────── ExactRoute { ea_method: "GET", ea_path: "/ut/game/fut/squad/list", core_method: "GET", core_path: "/squads", @@ -697,7 +704,7 @@ mod tests { #[test] fn test_total_exact_routes_count() { - assert!(EXACT.len() >= 55, "expected at least 55 exact mappings, got {}", EXACT.len()); + assert!(EXACT.len() >= 56, "expected at least 56 exact mappings, got {}", EXACT.len()); } // ── Phase 22 new mappings ─────────────────────────────────────────────────