mishrabp commited on
Commit
dcf1a98
·
verified ·
1 Parent(s): 7617015

Upload folder using huggingface_hub

Browse files
src/fifa/backend/main.py CHANGED
@@ -1,6 +1,7 @@
1
  """FastAPI backend for FIFA World Cup 2026 dashboard."""
2
  from __future__ import annotations
3
  import os
 
4
  from datetime import datetime
5
  from fastapi import FastAPI, HTTPException
6
  from fastapi.middleware.cors import CORSMiddleware
@@ -82,6 +83,89 @@ async def get_summary():
82
  }
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  @app.post("/api/refresh")
86
  async def refresh_cache():
87
  global _cache_ts
@@ -98,6 +182,9 @@ if os.path.exists(frontend_dist):
98
  assets_dir = os.path.join(frontend_dist, "assets")
99
  if os.path.exists(assets_dir):
100
  app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
 
 
 
101
 
102
  @app.get("/{rest_of_path:path}")
103
  async def serve_spa(rest_of_path: str):
 
1
  """FastAPI backend for FIFA World Cup 2026 dashboard."""
2
  from __future__ import annotations
3
  import os
4
+ import requests
5
  from datetime import datetime
6
  from fastapi import FastAPI, HTTPException
7
  from fastapi.middleware.cors import CORSMiddleware
 
83
  }
84
 
85
 
86
+ _ESPN_BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world"
87
+ _ESPN_STANDINGS = "https://site.api.espn.com/apis/v2/sports/soccer/fifa.world/standings"
88
+
89
+ _KNOCKOUT_ROUNDS = {
90
+ "r32": ["round of 32"],
91
+ "r16": ["round of 16"],
92
+ "qf": ["quarterfinal", "quarter-final", "quarter final"],
93
+ "sf": ["semifinal", "semi-final", "semi final"],
94
+ "final": ["final"],
95
+ "third": ["third place", "3rd place"],
96
+ }
97
+
98
+
99
+ def _classify_round(round_name: str) -> str | None:
100
+ rn = round_name.lower()
101
+ for key, patterns in _KNOCKOUT_ROUNDS.items():
102
+ if any(p in rn for p in patterns):
103
+ return key
104
+ return None
105
+
106
+
107
+ def _fetch_standings() -> list[dict]:
108
+ try:
109
+ resp = requests.get(_ESPN_STANDINGS, timeout=15)
110
+ resp.raise_for_status()
111
+ data = resp.json()
112
+ except Exception:
113
+ return []
114
+
115
+ groups = []
116
+ for group in data.get("children", []):
117
+ group_name = group.get("name", "")
118
+ entries = group.get("standings", {}).get("entries", [])
119
+ rows = []
120
+ for entry in entries:
121
+ team = entry.get("team", {})
122
+ logos = team.get("logos", [])
123
+ stats_raw = {s["abbreviation"]: s["displayValue"] for s in entry.get("stats", [])}
124
+ note = entry.get("note", {})
125
+ rows.append({
126
+ "pos": int(stats_raw.get("R", 0) or 0),
127
+ "team": team.get("displayName", ""),
128
+ "logo": logos[0].get("href", "") if logos else "",
129
+ "gp": stats_raw.get("GP", "0"),
130
+ "w": stats_raw.get("W", "0"),
131
+ "d": stats_raw.get("D", "0"),
132
+ "l": stats_raw.get("L", "0"),
133
+ "gf": stats_raw.get("F", "0"),
134
+ "ga": stats_raw.get("A", "0"),
135
+ "gd": stats_raw.get("GD", "0"),
136
+ "pts": stats_raw.get("P", "0"),
137
+ "advanced": int(stats_raw.get("ADV", 0) or 0),
138
+ "note": note.get("description", ""),
139
+ "note_color": note.get("color", ""),
140
+ })
141
+ rows.sort(key=lambda r: r["pos"])
142
+ groups.append({"group": group_name, "rows": rows})
143
+ return groups
144
+
145
+
146
+ def _fetch_knockout() -> dict:
147
+ data = _get_data()
148
+ all_matches = data.get("scores", []) + data.get("schedule", [])
149
+ buckets: dict[str, list] = {k: [] for k in _KNOCKOUT_ROUNDS}
150
+ seen: set = set()
151
+ for m in all_matches:
152
+ mid = m.get("match_id", "")
153
+ if mid in seen:
154
+ continue
155
+ seen.add(mid)
156
+ key = _classify_round(m.get("round", ""))
157
+ if key:
158
+ buckets[key].append(m)
159
+ return buckets
160
+
161
+
162
+ @app.get("/api/standings")
163
+ async def get_standings():
164
+ groups = _fetch_standings()
165
+ knockout = _fetch_knockout()
166
+ return JSONResponse({"groups": groups, "knockout": knockout})
167
+
168
+
169
  @app.post("/api/refresh")
170
  async def refresh_cache():
171
  global _cache_ts
 
182
  assets_dir = os.path.join(frontend_dist, "assets")
183
  if os.path.exists(assets_dir):
184
  app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
185
+ icons_dir = os.path.join(frontend_dist, "icons")
186
+ if os.path.exists(icons_dir):
187
+ app.mount("/icons", StaticFiles(directory=icons_dir), name="icons")
188
 
189
  @app.get("/{rest_of_path:path}")
190
  async def serve_spa(rest_of_path: str):
src/fifa/frontend/public/icons/icon-192.png ADDED
src/fifa/frontend/public/icons/icon-512.png ADDED
src/fifa/frontend/src/App.vue CHANGED
@@ -10,9 +10,10 @@ const installPrompt = ref(null)
10
  const showInstall = ref(false)
11
 
12
  const tabs = [
13
- { path: '/scores', label: "Today's Scores", icon: '⚽' },
14
- { path: '/schedule', label: 'Schedule', icon: '📅' },
15
- { path: '/news', label: 'News', icon: '📰' },
 
16
  ]
17
 
18
  async function loadSummary() {
@@ -31,8 +32,7 @@ async function doRefresh() {
31
  function formatLastUpdated(iso) {
32
  if (!iso) return 'Never'
33
  const d = new Date(iso)
34
- return d.toLocaleString('en-US', { timeZone: 'America/Chicago', hour12: true,
35
- month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + ' CDT'
36
  }
37
 
38
  onMounted(() => {
 
10
  const showInstall = ref(false)
11
 
12
  const tabs = [
13
+ { path: '/scores', label: "Today's Scores", icon: '⚽' },
14
+ { path: '/schedule', label: 'Schedule', icon: '📅' },
15
+ { path: '/standings', label: 'Standings', icon: '🏅' },
16
+ { path: '/news', label: 'News', icon: '📰' },
17
  ]
18
 
19
  async function loadSummary() {
 
32
  function formatLastUpdated(iso) {
33
  if (!iso) return 'Never'
34
  const d = new Date(iso)
35
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
 
36
  }
37
 
38
  onMounted(() => {
src/fifa/frontend/src/main.js CHANGED
@@ -4,6 +4,7 @@ import App from './App.vue'
4
  import ScoresView from './views/ScoresView.vue'
5
  import ScheduleView from './views/ScheduleView.vue'
6
  import NewsView from './views/NewsView.vue'
 
7
  import './style.css'
8
 
9
  const router = createRouter({
@@ -13,6 +14,7 @@ const router = createRouter({
13
  { path: '/scores', component: ScoresView, meta: { title: 'Today\'s Scores' } },
14
  { path: '/schedule', component: ScheduleView, meta: { title: 'Upcoming Schedule' } },
15
  { path: '/news', component: NewsView, meta: { title: 'News' } },
 
16
  ]
17
  })
18
 
 
4
  import ScoresView from './views/ScoresView.vue'
5
  import ScheduleView from './views/ScheduleView.vue'
6
  import NewsView from './views/NewsView.vue'
7
+ import StandingsView from './views/StandingsView.vue'
8
  import './style.css'
9
 
10
  const router = createRouter({
 
14
  { path: '/scores', component: ScoresView, meta: { title: 'Today\'s Scores' } },
15
  { path: '/schedule', component: ScheduleView, meta: { title: 'Upcoming Schedule' } },
16
  { path: '/news', component: NewsView, meta: { title: 'News' } },
17
+ { path: '/standings', component: StandingsView, meta: { title: 'Standings' } },
18
  ]
19
  })
20
 
src/fifa/frontend/src/services/api.js CHANGED
@@ -6,4 +6,5 @@ export const getScores = () => api.get('/scores').then(r => r.data)
6
  export const getSchedule = () => api.get('/schedule').then(r => r.data)
7
  export const getNews = () => api.get('/news').then(r => r.data)
8
  export const getSummary = () => api.get('/summary').then(r => r.data)
 
9
  export const refreshCache = () => api.post('/refresh').then(r => r.data)
 
6
  export const getSchedule = () => api.get('/schedule').then(r => r.data)
7
  export const getNews = () => api.get('/news').then(r => r.data)
8
  export const getSummary = () => api.get('/summary').then(r => r.data)
9
+ export const getStandings = () => api.get('/standings').then(r => r.data)
10
  export const refreshCache = () => api.post('/refresh').then(r => r.data)
src/fifa/frontend/src/utils/time.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Parse a "YYYY-MM-DD HH:MM AM/PM CDT" string (CDT = UTC-5) into a JS Date.
3
+ * Returns null if unparseable.
4
+ */
5
+ function parseCdt(timeCdt) {
6
+ if (!timeCdt || timeCdt === 'TBD') return null
7
+ const m = timeCdt.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})\s+(AM|PM)/)
8
+ if (!m) return null
9
+ const [, yr, mo, dy, hStr, min, ampm] = m
10
+ let h = parseInt(hStr)
11
+ if (ampm === 'PM' && h !== 12) h += 12
12
+ if (ampm === 'AM' && h === 12) h = 0
13
+ // CDT = UTC-5; add 5 hours to get UTC milliseconds
14
+ return new Date(Date.UTC(+yr, +mo - 1, +dy, h + 5, +min))
15
+ }
16
+
17
+ /** "Jun 24, 2:30 PM" in the browser's local timezone */
18
+ export function toLocalDateTime(timeCdt) {
19
+ const d = parseCdt(timeCdt)
20
+ if (!d) return timeCdt || ''
21
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
22
+ }
23
+
24
+ /** "2:30 PM" in the browser's local timezone */
25
+ export function toLocalTime(timeCdt) {
26
+ const d = parseCdt(timeCdt)
27
+ if (!d) return timeCdt || ''
28
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
29
+ }
30
+
31
+ /** Short TZ abbreviation from the browser, e.g. "EDT", "PDT" */
32
+ export function localTZAbbr() {
33
+ return new Date().toLocaleTimeString(undefined, { timeZoneName: 'short' }).split(' ').pop()
34
+ }
src/fifa/frontend/src/views/ScheduleView.vue CHANGED
@@ -1,6 +1,9 @@
1
  <script setup>
2
  import { ref, computed, onMounted } from 'vue'
3
  import { getSchedule } from '../services/api.js'
 
 
 
4
 
5
  const loading = ref(true)
6
  const error = ref(null)
@@ -46,7 +49,7 @@ onMounted(async () => {
46
  <template>
47
  <div class="schedule-view">
48
  <div class="view-header">
49
- <h1 class="view-title">📅 Upcoming Schedule <span class="subtitle">(CDT)</span></h1>
50
  <div class="date-filter" v-if="uniqueDates.length > 2">
51
  <select v-model="selectedDate" class="date-select">
52
  <option value="all">All Dates</option>
@@ -80,8 +83,8 @@ onMounted(async () => {
80
  <div class="day-matches">
81
  <div v-for="match in dayMatches" :key="match.match_id || match.home_team" class="schedule-row card">
82
  <div class="sched-time">
83
- <span class="time-main">{{ match.time_cdt ? match.time_cdt.split(' ').slice(1, 3).join(' ') : 'TBD' }}</span>
84
- <span class="time-tz">CDT</span>
85
  </div>
86
 
87
  <div class="sched-teams">
 
1
  <script setup>
2
  import { ref, computed, onMounted } from 'vue'
3
  import { getSchedule } from '../services/api.js'
4
+ import { toLocalTime, localTZAbbr } from '../utils/time.js'
5
+
6
+ const tz = localTZAbbr()
7
 
8
  const loading = ref(true)
9
  const error = ref(null)
 
49
  <template>
50
  <div class="schedule-view">
51
  <div class="view-header">
52
+ <h1 class="view-title">📅 Upcoming Schedule <span class="subtitle">({{ tz }})</span></h1>
53
  <div class="date-filter" v-if="uniqueDates.length > 2">
54
  <select v-model="selectedDate" class="date-select">
55
  <option value="all">All Dates</option>
 
83
  <div class="day-matches">
84
  <div v-for="match in dayMatches" :key="match.match_id || match.home_team" class="schedule-row card">
85
  <div class="sched-time">
86
+ <span class="time-main">{{ toLocalTime(match.time_cdt) || 'TBD' }}</span>
87
+ <span class="time-tz">{{ tz }}</span>
88
  </div>
89
 
90
  <div class="sched-teams">
src/fifa/frontend/src/views/ScoresView.vue CHANGED
@@ -1,11 +1,14 @@
1
  <script setup>
2
- import { ref, computed, onMounted } from 'vue'
3
  import { getScores } from '../services/api.js'
 
4
 
5
  const loading = ref(true)
6
  const error = ref(null)
7
  const matches = ref([])
8
  const filter = ref('all')
 
 
9
 
10
  const filtered = computed(() => {
11
  if (filter.value === 'all') return matches.value
@@ -33,6 +36,11 @@ function badgeClass(m) {
33
  return 'badge-tbd'
34
  }
35
 
 
 
 
 
 
36
  function scoreDisplay(m) {
37
  const hs = m.home_score
38
  const as = m.away_score
@@ -40,6 +48,24 @@ function scoreDisplay(m) {
40
  return `${hs} – ${as}`
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  onMounted(async () => {
44
  try {
45
  const data = await getScores()
@@ -49,7 +75,10 @@ onMounted(async () => {
49
  } finally {
50
  loading.value = false
51
  }
 
52
  })
 
 
53
  </script>
54
 
55
  <template>
@@ -89,8 +118,8 @@ onMounted(async () => {
89
  <div v-for="match in filtered" :key="match.match_id || match.home_team" class="match-card card">
90
  <!-- Status bar -->
91
  <div class="match-status-bar">
92
- <span class="badge" :class="badgeClass(match)">{{ match.status || 'TBD' }}</span>
93
- <span class="match-time">{{ match.time_cdt }}</span>
94
  <span class="match-round">{{ match.round }}</span>
95
  </div>
96
 
 
1
  <script setup>
2
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
3
  import { getScores } from '../services/api.js'
4
+ import { toLocalDateTime, localTZAbbr } from '../utils/time.js'
5
 
6
  const loading = ref(true)
7
  const error = ref(null)
8
  const matches = ref([])
9
  const filter = ref('all')
10
+ const tz = localTZAbbr()
11
+ let pollTimer = null
12
 
13
  const filtered = computed(() => {
14
  if (filter.value === 'all') return matches.value
 
36
  return 'badge-tbd'
37
  }
38
 
39
+ function statusLabel(m) {
40
+ if (isLive(m)) return 'LIVE'
41
+ return m.status || 'TBD'
42
+ }
43
+
44
  function scoreDisplay(m) {
45
  const hs = m.home_score
46
  const as = m.away_score
 
48
  return `${hs} – ${as}`
49
  }
50
 
51
+ async function fetchData() {
52
+ try {
53
+ const data = await getScores()
54
+ matches.value = Array.isArray(data) ? data : []
55
+ } catch {}
56
+ }
57
+
58
+ function schedulePoll() {
59
+ clearInterval(pollTimer)
60
+ const hasLive = matches.value.some(isLive)
61
+ // 60 s when live, 5 min otherwise
62
+ const interval = hasLive ? 60_000 : 300_000
63
+ pollTimer = setInterval(async () => {
64
+ await fetchData()
65
+ schedulePoll() // re-evaluate interval after each fetch
66
+ }, interval)
67
+ }
68
+
69
  onMounted(async () => {
70
  try {
71
  const data = await getScores()
 
75
  } finally {
76
  loading.value = false
77
  }
78
+ schedulePoll()
79
  })
80
+
81
+ onUnmounted(() => clearInterval(pollTimer))
82
  </script>
83
 
84
  <template>
 
118
  <div v-for="match in filtered" :key="match.match_id || match.home_team" class="match-card card">
119
  <!-- Status bar -->
120
  <div class="match-status-bar">
121
+ <span class="badge" :class="badgeClass(match)">{{ statusLabel(match) }}</span>
122
+ <span class="match-time">{{ toLocalDateTime(match.time_cdt) }} {{ tz }}</span>
123
  <span class="match-round">{{ match.round }}</span>
124
  </div>
125
 
src/fifa/frontend/src/views/StandingsView.vue ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script setup>
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { getStandings } from '../services/api.js'
4
+ import { toLocalDateTime, localTZAbbr } from '../utils/time.js'
5
+
6
+ const tz = localTZAbbr()
7
+
8
+ const loading = ref(true)
9
+ const error = ref(null)
10
+ const groups = ref([])
11
+ const knockout = ref({})
12
+ const activeGroup = ref(null)
13
+ const activeSection = ref('groups') // 'groups' | 'knockout'
14
+
15
+ const knockoutLabels = {
16
+ r32: { label: 'Round of 32', icon: '32' },
17
+ r16: { label: 'Round of 16', icon: '16' },
18
+ qf: { label: 'Quarterfinals', icon: 'QF' },
19
+ sf: { label: 'Semifinals', icon: 'SF' },
20
+ third: { label: 'Third Place', icon: '3rd' },
21
+ final: { label: 'Final', icon: '🏆' },
22
+ }
23
+
24
+ const knockoutOrder = ['r32', 'r16', 'qf', 'sf', 'third', 'final']
25
+
26
+ const hasKnockout = computed(() =>
27
+ knockoutOrder.some(k => (knockout.value[k] || []).length > 0)
28
+ )
29
+
30
+ function noteStyle(row) {
31
+ if (!row.note_color) return {}
32
+ const hex = row.note_color.replace('#', '')
33
+ const r = parseInt(hex.slice(0,2),16), g = parseInt(hex.slice(2,4),16), b = parseInt(hex.slice(4,6),16)
34
+ return { borderLeft: `3px solid ${row.note_color}`, background: `rgba(${r},${g},${b},0.08)` }
35
+ }
36
+
37
+ function scoreDisplay(m) {
38
+ const hs = m.home_score, as = m.away_score
39
+ if (hs === '' || hs == null) return 'vs'
40
+ return `${hs} – ${as}`
41
+ }
42
+ function isLive(m) { return /live|progress|half/i.test(m.status || '') }
43
+ function isFinal(m) { return /final|ft|ended/i.test(m.status || '') }
44
+ function badgeClass(m) {
45
+ if (isLive(m)) return 'badge-live'
46
+ if (isFinal(m)) return 'badge-final'
47
+ return 'badge-upcoming'
48
+ }
49
+ function statusLabel(m) { return isLive(m) ? 'LIVE' : (m.status || 'TBD') }
50
+
51
+ onMounted(async () => {
52
+ try {
53
+ const data = await getStandings()
54
+ groups.value = data.groups || []
55
+ knockout.value = data.knockout || {}
56
+ if (groups.value.length) activeGroup.value = groups.value[0].group
57
+ } catch {
58
+ error.value = 'Failed to load standings.'
59
+ } finally {
60
+ loading.value = false
61
+ }
62
+ })
63
+
64
+ const activeGroupData = computed(() =>
65
+ groups.value.find(g => g.group === activeGroup.value) || null
66
+ )
67
+ </script>
68
+
69
+ <template>
70
+ <div class="standings-view">
71
+ <div class="view-header">
72
+ <h1 class="view-title">🏅 Standings &amp; Bracket</h1>
73
+ <div class="section-toggle">
74
+ <button class="toggle-btn" :class="{ active: activeSection === 'groups' }" @click="activeSection = 'groups'">
75
+ 📊 Group Stage
76
+ </button>
77
+ <button class="toggle-btn" :class="{ active: activeSection === 'knockout' }" @click="activeSection = 'knockout'">
78
+ 🥊 Knockout
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ <div v-if="loading" class="loading-spinner">
84
+ <div class="spinner"></div>
85
+ <span>Loading standings…</span>
86
+ </div>
87
+
88
+ <div v-else-if="error" class="empty-state">
89
+ <div class="empty-icon">⚠️</div>
90
+ <p>{{ error }}</p>
91
+ </div>
92
+
93
+ <template v-else>
94
+ <!-- ── GROUP STAGE ── -->
95
+ <template v-if="activeSection === 'groups'">
96
+ <!-- Group selector -->
97
+ <div class="group-tabs">
98
+ <button
99
+ v-for="g in groups"
100
+ :key="g.group"
101
+ class="group-tab"
102
+ :class="{ active: activeGroup === g.group }"
103
+ @click="activeGroup = g.group"
104
+ >{{ g.group }}</button>
105
+ </div>
106
+
107
+ <!-- Group table -->
108
+ <div v-if="activeGroupData" class="group-card card">
109
+ <div class="group-title">{{ activeGroupData.group }}</div>
110
+ <div class="table-wrap">
111
+ <table class="standings-table">
112
+ <thead>
113
+ <tr>
114
+ <th class="col-pos">#</th>
115
+ <th class="col-team">Team</th>
116
+ <th title="Games Played">GP</th>
117
+ <th title="Wins">W</th>
118
+ <th title="Draws">D</th>
119
+ <th title="Losses">L</th>
120
+ <th title="Goals For">GF</th>
121
+ <th title="Goals Against">GA</th>
122
+ <th title="Goal Difference">GD</th>
123
+ <th class="col-pts" title="Points">Pts</th>
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ <tr
128
+ v-for="row in activeGroupData.rows"
129
+ :key="row.team"
130
+ class="team-row"
131
+ :style="noteStyle(row)"
132
+ >
133
+ <td class="col-pos">{{ row.pos }}</td>
134
+ <td class="col-team">
135
+ <img v-if="row.logo" :src="row.logo" :alt="row.team" class="tbl-logo" />
136
+ <span class="tbl-name">{{ row.team }}</span>
137
+ </td>
138
+ <td>{{ row.gp }}</td>
139
+ <td>{{ row.w }}</td>
140
+ <td>{{ row.d }}</td>
141
+ <td>{{ row.l }}</td>
142
+ <td>{{ row.gf }}</td>
143
+ <td>{{ row.ga }}</td>
144
+ <td :class="{ 'gd-pos': Number(row.gd) > 0, 'gd-neg': Number(row.gd) < 0 }">{{ row.gd }}</td>
145
+ <td class="col-pts">{{ row.pts }}</td>
146
+ </tr>
147
+ </tbody>
148
+ </table>
149
+ </div>
150
+
151
+ <!-- Legend -->
152
+ <div class="legend" v-if="activeGroupData.rows.some(r => r.note)">
153
+ <div
154
+ v-for="row in activeGroupData.rows.filter(r => r.note)"
155
+ :key="row.note"
156
+ class="legend-item"
157
+ >
158
+ <span class="legend-dot" :style="{ background: row.note_color }"></span>
159
+ <span>{{ row.note }}</span>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- All groups mini-view (scroll) -->
165
+ <div class="all-groups">
166
+ <div v-for="g in groups" :key="g.group" class="mini-group card" @click="activeGroup = g.group; window?.scrollTo(0,0)">
167
+ <div class="mini-group-title">{{ g.group }}</div>
168
+ <div class="mini-rows">
169
+ <div v-for="row in g.rows" :key="row.team" class="mini-row" :style="noteStyle(row)">
170
+ <span class="mini-pos">{{ row.pos }}</span>
171
+ <img v-if="row.logo" :src="row.logo" :alt="row.team" class="mini-logo" />
172
+ <span class="mini-name">{{ row.team }}</span>
173
+ <span class="mini-pts">{{ row.pts }}pts</span>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </template>
179
+
180
+ <!-- ── KNOCKOUT ── -->
181
+ <template v-if="activeSection === 'knockout'">
182
+ <div v-if="!hasKnockout" class="empty-state">
183
+ <div class="empty-icon">🔜</div>
184
+ <p>Knockout stage hasn't started yet.</p>
185
+ </div>
186
+
187
+ <div v-else class="knockout-sections">
188
+ <div
189
+ v-for="key in knockoutOrder"
190
+ :key="key"
191
+ v-show="(knockout[key] || []).length > 0"
192
+ class="knockout-round"
193
+ >
194
+ <div class="round-header">
195
+ <span class="round-badge">{{ knockoutLabels[key].icon }}</span>
196
+ <span class="round-name">{{ knockoutLabels[key].label }}</span>
197
+ <span class="round-count">{{ (knockout[key] || []).length }} match{{ (knockout[key] || []).length !== 1 ? 'es' : '' }}</span>
198
+ </div>
199
+
200
+ <div class="ko-grid">
201
+ <div v-for="match in knockout[key]" :key="match.match_id || match.home_team" class="ko-card card">
202
+ <div class="ko-status-bar">
203
+ <span class="badge" :class="badgeClass(match)">{{ statusLabel(match) }}</span>
204
+ <span class="ko-time">{{ toLocalDateTime(match.time_cdt) }} {{ tz }}</span>
205
+ </div>
206
+ <div class="ko-teams">
207
+ <div class="ko-team">
208
+ <img v-if="match.home_logo" :src="match.home_logo" :alt="match.home_team" class="ko-logo" />
209
+ <span class="ko-name">{{ match.home_team }}</span>
210
+ <span class="ko-score" :class="{ 'score-live': isLive(match) }">
211
+ {{ match.home_score !== '' && match.home_score != null ? match.home_score : '-' }}
212
+ </span>
213
+ </div>
214
+ <div class="ko-sep">vs</div>
215
+ <div class="ko-team ko-team-away">
216
+ <span class="ko-score ko-score-right" :class="{ 'score-live': isLive(match) }">
217
+ {{ match.away_score !== '' && match.away_score != null ? match.away_score : '-' }}
218
+ </span>
219
+ <span class="ko-name ko-name-right">{{ match.away_team }}</span>
220
+ <img v-if="match.away_logo" :src="match.away_logo" :alt="match.away_team" class="ko-logo" />
221
+ </div>
222
+ </div>
223
+ <div class="ko-venue" v-if="match.venue">📍 {{ match.venue }}<span v-if="match.city">, {{ match.city }}</span></div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </template>
229
+ </template>
230
+ </div>
231
+ </template>
232
+
233
+ <style scoped>
234
+ .standings-view { display: flex; flex-direction: column; gap: 20px; }
235
+
236
+ .view-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
237
+ .view-title { font-size: 22px; font-weight: 700; }
238
+
239
+ .section-toggle { display: flex; gap: 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--border); }
240
+ .toggle-btn {
241
+ padding: 8px 18px;
242
+ background: transparent;
243
+ border: none;
244
+ color: var(--text-muted);
245
+ cursor: pointer;
246
+ font-size: 13px;
247
+ font-weight: 500;
248
+ transition: all 0.2s;
249
+ }
250
+ .toggle-btn.active { background: rgba(255,215,0,0.15); color: var(--accent-gold); }
251
+
252
+ /* Group tabs */
253
+ .group-tabs {
254
+ display: flex;
255
+ flex-wrap: wrap;
256
+ gap: 6px;
257
+ }
258
+ .group-tab {
259
+ padding: 6px 14px;
260
+ border-radius: 20px;
261
+ border: 1px solid var(--border);
262
+ background: transparent;
263
+ color: var(--text-muted);
264
+ cursor: pointer;
265
+ font-size: 13px;
266
+ font-weight: 600;
267
+ transition: all 0.2s;
268
+ }
269
+ .group-tab:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
270
+ .group-tab.active { background: rgba(255,215,0,0.15); border-color: var(--accent-gold); color: var(--accent-gold); }
271
+
272
+ /* Group card */
273
+ .group-card { overflow: hidden; }
274
+ .group-title {
275
+ padding: 12px 16px;
276
+ font-size: 16px;
277
+ font-weight: 700;
278
+ color: var(--accent-gold);
279
+ border-bottom: 1px solid var(--border);
280
+ background: rgba(0,0,0,0.2);
281
+ }
282
+
283
+ .table-wrap { overflow-x: auto; }
284
+ .standings-table {
285
+ width: 100%;
286
+ border-collapse: collapse;
287
+ font-size: 14px;
288
+ }
289
+ .standings-table th {
290
+ padding: 8px 10px;
291
+ text-align: center;
292
+ color: var(--text-muted);
293
+ font-size: 11px;
294
+ font-weight: 600;
295
+ text-transform: uppercase;
296
+ border-bottom: 1px solid var(--border);
297
+ background: rgba(0,0,0,0.15);
298
+ letter-spacing: 0.5px;
299
+ }
300
+ .standings-table th.col-team { text-align: left; }
301
+ .standings-table td {
302
+ padding: 10px 10px;
303
+ text-align: center;
304
+ border-bottom: 1px solid rgba(255,255,255,0.04);
305
+ }
306
+ .team-row:last-child td { border-bottom: none; }
307
+ .team-row:hover { background: rgba(255,255,255,0.03); }
308
+
309
+ .col-pos { width: 36px; font-weight: 700; color: var(--text-secondary); }
310
+ .col-team { text-align: left; }
311
+ .col-pts { font-weight: 800; color: var(--accent-gold); font-size: 15px; }
312
+
313
+ .tbl-logo { width: 22px; height: 22px; object-fit: contain; vertical-align: middle; margin-right: 8px; }
314
+ .tbl-name { vertical-align: middle; font-weight: 500; }
315
+
316
+ .gd-pos { color: var(--accent-green); font-weight: 600; }
317
+ .gd-neg { color: var(--accent-red); font-weight: 600; }
318
+
319
+ .legend {
320
+ display: flex;
321
+ flex-wrap: wrap;
322
+ gap: 12px;
323
+ padding: 10px 16px;
324
+ border-top: 1px solid var(--border);
325
+ }
326
+ .legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
327
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
328
+
329
+ /* All groups mini grid */
330
+ .all-groups {
331
+ display: grid;
332
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
333
+ gap: 12px;
334
+ }
335
+ .mini-group { padding: 0; overflow: hidden; cursor: pointer; }
336
+ .mini-group:hover { transform: translateY(-2px); }
337
+ .mini-group-title {
338
+ padding: 8px 12px;
339
+ font-size: 13px;
340
+ font-weight: 700;
341
+ color: var(--accent-gold);
342
+ background: rgba(0,0,0,0.2);
343
+ border-bottom: 1px solid var(--border);
344
+ }
345
+ .mini-rows { display: flex; flex-direction: column; }
346
+ .mini-row {
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 8px;
350
+ padding: 7px 12px;
351
+ border-bottom: 1px solid rgba(255,255,255,0.04);
352
+ font-size: 13px;
353
+ }
354
+ .mini-row:last-child { border-bottom: none; }
355
+ .mini-pos { width: 16px; color: var(--text-muted); font-weight: 700; font-size: 12px; flex-shrink: 0; }
356
+ .mini-logo { width: 18px; height: 18px; object-fit: contain; flex-shrink: 0; }
357
+ .mini-name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
358
+ .mini-pts { font-weight: 700; color: var(--accent-gold); font-size: 12px; flex-shrink: 0; }
359
+
360
+ /* Knockout */
361
+ .knockout-sections { display: flex; flex-direction: column; gap: 28px; }
362
+ .knockout-round { display: flex; flex-direction: column; gap: 12px; }
363
+
364
+ .round-header {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 10px;
368
+ padding-bottom: 8px;
369
+ border-bottom: 1px solid var(--border);
370
+ }
371
+ .round-badge {
372
+ display: inline-flex;
373
+ align-items: center;
374
+ justify-content: center;
375
+ min-width: 36px;
376
+ height: 26px;
377
+ padding: 0 8px;
378
+ border-radius: 6px;
379
+ background: rgba(255,215,0,0.15);
380
+ border: 1px solid var(--accent-gold);
381
+ color: var(--accent-gold);
382
+ font-size: 12px;
383
+ font-weight: 800;
384
+ }
385
+ .round-name { font-size: 17px; font-weight: 700; }
386
+ .round-count { font-size: 12px; color: var(--text-muted); margin-left: auto; }
387
+
388
+ .ko-grid {
389
+ display: grid;
390
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
391
+ gap: 12px;
392
+ }
393
+ .ko-card { overflow: hidden; padding: 0; }
394
+ .ko-status-bar {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ padding: 8px 14px;
399
+ background: rgba(0,0,0,0.3);
400
+ border-bottom: 1px solid var(--border);
401
+ }
402
+ .ko-time { font-size: 11px; color: var(--text-muted); margin-left: auto; }
403
+
404
+ .ko-teams {
405
+ display: flex;
406
+ align-items: center;
407
+ padding: 14px;
408
+ gap: 8px;
409
+ }
410
+ .ko-team {
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 8px;
414
+ flex: 1;
415
+ }
416
+ .ko-team-away { flex-direction: row-reverse; }
417
+ .ko-logo { width: 32px; height: 32px; object-fit: contain; flex-shrink: 0; }
418
+ .ko-name { font-size: 13px; font-weight: 600; flex: 1; }
419
+ .ko-name-right { text-align: right; }
420
+ .ko-sep { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
421
+ .ko-score {
422
+ font-size: 22px;
423
+ font-weight: 800;
424
+ min-width: 24px;
425
+ text-align: center;
426
+ flex-shrink: 0;
427
+ }
428
+ .ko-score-right { }
429
+ .score-live { color: var(--accent-red); }
430
+
431
+ .ko-venue {
432
+ padding: 6px 14px 10px;
433
+ font-size: 11px;
434
+ color: var(--text-muted);
435
+ border-top: 1px solid var(--border);
436
+ }
437
+
438
+ @media (max-width: 640px) {
439
+ .view-header { flex-direction: column; align-items: flex-start; }
440
+ .ko-grid { grid-template-columns: 1fr; }
441
+ .all-groups { grid-template-columns: 1fr 1fr; }
442
+ }
443
+ @media (max-width: 400px) {
444
+ .all-groups { grid-template-columns: 1fr; }
445
+ }
446
+ </style>