diff --git a/assets/css/animations.css b/assets/css/animations.css new file mode 100644 index 0000000..fb09b48 --- /dev/null +++ b/assets/css/animations.css @@ -0,0 +1,135 @@ +/* animations.css — entry (fade-in, slide-up, slide-left) and interaction + (hover-scale, hover-glow, pulse) animations. Bracket-specific set + (line-draw, winner/path-highlight) lands in step 8. */ + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-up { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slide-left { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.fade-in { + animation: fade-in 0.5s ease both; +} + +.slide-up { + animation: slide-up 0.55s ease both; +} + +.slide-left { + animation: slide-left 0.55s ease both; +} + +.pulse { + animation: pulse 1.6s ease-in-out infinite; +} + +.hover-scale { + transition: transform 0.25s ease; +} + +.hover-scale:hover { + transform: scale(1.03); +} + +.hover-glow { + transition: box-shadow 0.25s ease, border-color 0.25s ease; +} + +.hover-glow:hover { + border-color: rgba(212, 175, 55, 0.5); + box-shadow: 0 0 22px rgba(212, 175, 55, 0.25); +} + +/* ------------------------------------------------------ entry set */ + +/* every tab switch animates the incoming panel */ +.panel:not([hidden]) { + animation: fade-in 0.35s ease; +} + +/* card grids slide up with a small stagger on the first rows */ +.match-grid > *, +.groups-grid > *, +.stadiums-grid > * { + animation: slide-up 0.4s ease both; +} + +.match-grid > :nth-child(2), .groups-grid > :nth-child(2), .stadiums-grid > :nth-child(2) { animation-delay: 45ms; } +.match-grid > :nth-child(3), .groups-grid > :nth-child(3), .stadiums-grid > :nth-child(3) { animation-delay: 90ms; } +.match-grid > :nth-child(4), .groups-grid > :nth-child(4), .stadiums-grid > :nth-child(4) { animation-delay: 135ms; } +.match-grid > :nth-child(5), .groups-grid > :nth-child(5), .stadiums-grid > :nth-child(5) { animation-delay: 180ms; } +.match-grid > :nth-child(6), .groups-grid > :nth-child(6), .stadiums-grid > :nth-child(6) { animation-delay: 225ms; } +.match-grid > :nth-child(n+7), .groups-grid > :nth-child(n+7), .stadiums-grid > :nth-child(n+7) { animation-delay: 260ms; } + +/* ------------------------------------------------------ bracket set */ + +@keyframes line-draw-x { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} + +@keyframes line-draw-y { + from { transform: scaleY(0); } + to { transform: scaleY(1); } +} + +@keyframes winner-glow { + from { box-shadow: inset 0 0 0 rgba(212, 175, 55, 0); } + 50% { box-shadow: inset 0 0 18px rgba(212, 175, 55, 0.35); } + to { box-shadow: inset 0 0 0 rgba(212, 175, 55, 0); } +} + +.bracket .bracket-match { + animation: fade-in 0.45s ease both; +} + +.bracket-round:nth-child(2) .bracket-match { animation-delay: 0.09s; } +.bracket-round:nth-child(3) .bracket-match { animation-delay: 0.18s; } +.bracket-round:nth-child(4) .bracket-match { animation-delay: 0.27s; } +.bracket-round:nth-child(5) .bracket-match { animation-delay: 0.36s; } + +.bracket-slot::before, +.bracket-slot::after { + transform-origin: left center; + animation: line-draw-x 0.5s ease 0.25s both; +} + +.bracket-pair::after { + transform-origin: center top; + animation: line-draw-y 0.5s ease 0.35s both; +} + +.bracket-team.winner { + animation: winner-glow 1.6s ease 0.5s 1; +} + +@media (prefers-reduced-motion: reduce) { + .fade-in, + .slide-up, + .slide-left, + .pulse { + animation: none; + } + + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/assets/css/bracket.css b/assets/css/bracket.css new file mode 100644 index 0000000..ab8ab4b --- /dev/null +++ b/assets/css/bracket.css @@ -0,0 +1,483 @@ +/* bracket.css — knockout bracket: horizontal round columns, CSS connectors, + match nodes, champion box. Geometry note: every column has the same height + and slots are flex:1, so a pair's children sit at 25% / 75% of the pair and + the next round's node at its 50% — connector lines meet exactly. + Interactions (hover path, zoom, drag) land in step 8. */ + +.bracket-toolbar { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.6rem; +} + +.bracket-tools-left, +.bracket-tools-right { + display: flex; + gap: 0.4rem; +} + +.sim-toggle { + font-size: 0.8rem; +} + +.sim-toggle.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #fff; +} + +#sim-reset { + font-size: 0.8rem; + font-weight: 400; + color: var(--text-secondary); +} + +.sim-note { + margin: 0 0 0.6rem; + font-size: 0.8rem; + color: var(--accent-blue); +} + +.zoom-btn { + min-width: 34px; + height: 34px; + padding-inline: 0.5rem; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: rgba(8, 20, 33, 0.6); + color: var(--text-primary); + font-weight: 700; + transition: border-color 0.2s; +} + +.zoom-btn:hover { + border-color: var(--accent-gold); +} + +.zoom-reset { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + color: var(--text-secondary); +} + +.bracket-wrap { + overflow: auto; + max-height: min(78vh, 900px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + background: rgba(8, 20, 33, 0.45); + -webkit-overflow-scrolling: touch; + cursor: grab; + touch-action: none; /* we implement pan + pinch ourselves via pointer events */ +} + +.bracket-wrap.dragging { + cursor: grabbing; + user-select: none; +} + +.bracket-zoom { + position: relative; + width: max-content; +} + +.bracket { + transform-origin: 0 0; +} + +.bracket { + --node-w: 200px; + --line: rgba(255, 255, 255, 0.18); + display: flex; + gap: 44px; + width: max-content; + min-height: 1150px; + padding: 1.25rem 1.5rem 1.5rem; +} + +.bracket-round { + display: flex; + flex-direction: column; + width: var(--node-w); +} + +.bracket-round-title { + margin: 0 0 0.75rem; + text-align: center; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent-gold); +} + +.bracket-matches { + flex: 1; + display: flex; + flex-direction: column; +} + +.bracket-pair { + flex: 1; + display: flex; + flex-direction: column; + position: relative; +} + +.bracket-slot { + flex: 1; + display: flex; + align-items: center; + position: relative; +} + +/* ----------------------------------------------------------- connectors */ + +/* out of a node toward the next column (half the 44px gap) */ +.bracket-round:not(.bracket-final-col) .bracket-slot::after { + content: ""; + position: absolute; + top: 50%; + right: -22px; + width: 22px; + height: 2px; + background: var(--line); +} + +/* vertical join between a pair's two out-stubs */ +.bracket-pair::after { + content: ""; + position: absolute; + top: 25%; + right: -22px; + width: 2px; + height: 50%; + background: var(--line); +} + +/* into a node from the previous column */ +.bracket-round:not(:first-child) .bracket-slot::before { + content: ""; + position: absolute; + top: 50%; + left: -22px; + width: 22px; + height: 2px; + background: var(--line); +} + +.bracket-champion-slot::before, +.bracket-third-slot::before { + display: none; +} + +/* --------------------------------------------------------- match nodes */ + +.bracket-match { + position: relative; + z-index: 1; + width: 100%; + overflow: hidden; + background: rgba(16, 36, 59, 0.85); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.bracket-match:hover { + border-color: rgba(212, 175, 55, 0.55); + box-shadow: 0 0 14px rgba(212, 175, 55, 0.18); +} + +.bracket-match.is-live { + border-color: rgba(255, 77, 90, 0.6); +} + +.bracket-team { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.6rem; + font-size: 0.78rem; +} + +.bracket-team + .bracket-team { + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.bracket-team .flag { + flex: none; +} + +.bt-flag-ph { + flex: none; + width: 20px; + height: 14px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); +} + +.bt-name { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 600; +} + +.bracket-team.tbd .bt-name { + font-weight: 400; + font-style: italic; + color: var(--text-secondary); +} + +.bracket-team.winner { + background: rgba(212, 175, 55, 0.1); +} + +.bracket-team.winner .bt-name { + color: var(--accent-gold); +} + +.bt-score { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.bt-score small { + font-weight: 400; + color: var(--text-secondary); +} + +/* --------------------------------------------------- final column extras */ + +.champion-box { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + width: 100%; + padding: 0.9rem 0.6rem; + text-align: center; + border: 1px dashed rgba(212, 175, 55, 0.5); + border-radius: var(--radius-sm); +} + +.champion-box.has-champion { + border-style: solid; + box-shadow: 0 0 18px rgba(212, 175, 55, 0.25); +} + +.champion-trophy { + font-size: 1.4rem; +} + +.champion-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.champion-name { + font-weight: 700; + color: var(--accent-gold); +} + +.third-place-block { + width: 100%; +} + +.third-place-block h4 { + margin: 0 0 0.4rem; + text-align: center; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +/* mobile: narrower nodes and gap — connector stubs must stay at gap/2 */ +@media (max-width: 767px) { + .bracket { + --node-w: 168px; + gap: 36px; + min-height: 1000px; + padding: 1rem 1rem 1.25rem; + } + + .bracket-round:not(.bracket-final-col) .bracket-slot::after { + right: -18px; + width: 18px; + } + + .bracket-pair::after { + right: -18px; + } + + .bracket-round:not(:first-child) .bracket-slot::before { + left: -18px; + width: 18px; + } + + .bracket-wrap { + max-height: 72vh; + } +} + +/* ----------------------------------------------------- challenge card */ + +.challenge-card { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.25rem; + padding: 0.85rem 1.1rem; + margin-bottom: 0.75rem; + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(16, 36, 59, 0.55); +} + +.challenge-title { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent-gold); +} + +.challenge-score { + font-weight: 700; +} + +.challenge-phase { + font-size: 0.75rem; + color: var(--text-secondary); + border: 1px solid var(--glass-border); + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-variant-numeric: tabular-nums; +} + +/* favorite team involved → gold edge, mirrors the schedule cards */ +.bracket-match.has-fav { + border-left: 3px solid var(--accent-gold); +} + +/* ------------------------------------------------------- simulation */ + +.sim-on .bracket-match.simulatable { + border-style: dashed; + border-color: rgba(30, 136, 229, 0.65); +} + +.sim-on .bracket-match.simulatable:hover { + border-color: var(--accent-blue); + box-shadow: 0 0 14px rgba(30, 136, 229, 0.3); +} + +.sim-chip { + position: absolute; + top: 0; + right: 0; + z-index: 1; + padding: 0.05rem 0.35rem; + border-radius: 0 0 0 6px; + background: var(--accent-blue); + color: #fff; + font-size: 0.55rem; + font-weight: 700; + letter-spacing: 0.06em; +} + +.bt-score.sim { + color: var(--accent-blue); +} + +.sim-modal .sim-hint { + margin: 0 0 1rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.sim-teams { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + margin-bottom: 1.1rem; +} + +.sim-team { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 0.8rem 0.5rem; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.04); + font-weight: 600; + transition: border-color 0.2s, background-color 0.2s; +} + +.sim-team:hover { + border-color: rgba(30, 136, 229, 0.6); +} + +.sim-team.active { + border-color: var(--accent-gold); + background: rgba(212, 175, 55, 0.12); +} + +.sim-scores { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + margin-bottom: 1.2rem; +} + +.sim-scores label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.sim-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* -------------------------------------------------- path highlight */ + +.bracket-match, +.bracket-slot::before, +.bracket-slot::after, +.bracket-pair::after { + transition: opacity 0.25s ease, background-color 0.25s ease, + border-color 0.25s ease, box-shadow 0.25s ease; +} + +.bracket.has-path .bracket-match:not(.path-on) { + opacity: 0.3; +} + +.bracket-match.path-on { + border-color: rgba(212, 175, 55, 0.75); + box-shadow: 0 0 16px rgba(212, 175, 55, 0.28); +} + +.bracket-slot.path-on::before, +.bracket-slot.path-on::after, +.bracket-pair.path-on::after { + background: var(--accent-gold); +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..f98b4bc --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,1025 @@ +/* style.css — palette variables, glassmorphism base, layout, components. + Mobile-first. Palette per spec §5. */ + +/* ------------------------------------------------------------ tokens */ + +:root { + color-scheme: dark; + --bg-primary: #081421; + --bg-secondary: #10243b; + + --accent-gold: #d4af37; + --accent-gold-soft: #f0cf6a; + --accent-blue: #1e88e5; + + --text-primary: #ffffff; + --text-secondary: #cfd8dc; + + --live: #ff4d5a; + + --glass-bg: rgba(255, 255, 255, 0.06); + --glass-bg-strong: rgba(255, 255, 255, 0.1); + --glass-border: rgba(255, 255, 255, 0.12); + --shadow-soft: 0 8px 32px rgba(0, 0, 0, 0.35); + + --radius: 18px; + --radius-sm: 10px; + + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* ------------------------------------------------------------- base */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + color: var(--text-primary); + background: var(--bg-primary); +} + +/* fixed gradient layer — cheaper than background-attachment: fixed, which + forces a repaint of the whole background on every scroll */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background: + radial-gradient(60rem 40rem at 110% -10%, rgba(30, 136, 229, 0.18), transparent 60%), + radial-gradient(50rem 35rem at -20% 110%, rgba(212, 175, 55, 0.12), transparent 60%), + linear-gradient(160deg, var(--bg-secondary), var(--bg-primary) 55%); +} + +img { + display: block; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +:focus-visible { + outline: 2px solid var(--accent-blue); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.container { + width: min(1200px, 100% - 2rem); + margin-inline: auto; +} + +.glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px) saturate(1.2); + -webkit-backdrop-filter: blur(14px) saturate(1.2); +} + +.skip-link { + position: absolute; + left: -999px; + top: 0.5rem; + z-index: 100; + padding: 0.6rem 1rem; + background: var(--accent-blue); + color: #fff; + border-radius: var(--radius-sm); + text-decoration: none; +} + +.skip-link:focus { + left: 0.5rem; +} + +/* ------------------------------------------------------------ header */ + +.site-header { + position: sticky; + top: 0; + z-index: 50; + background: rgba(8, 20, 33, 0.75); + border-bottom: 1px solid var(--glass-border); + backdrop-filter: blur(14px) saturate(1.2); + -webkit-backdrop-filter: blur(14px) saturate(1.2); +} + +.header-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; + padding-block: 0.7rem; +} + +.logo { + display: flex; + align-items: center; + gap: 0.55rem; + color: var(--text-primary); + text-decoration: none; + font-size: 1.05rem; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.logo svg { + width: 30px; + height: 30px; + color: var(--accent-gold); +} + +.logo strong { + color: var(--accent-gold); +} + +.tabs { + display: flex; + gap: 0.25rem; + overflow-x: auto; + scrollbar-width: none; + flex: 1 1 100%; + order: 3; +} + +.tabs::-webkit-scrollbar { + display: none; +} + +.tab-btn { + padding: 0.5rem 0.95rem; + border-radius: 999px; + color: var(--text-secondary); + white-space: nowrap; + transition: color 0.2s, background-color 0.2s, transform 0.2s; +} + +.tab-btn:hover { + color: var(--text-primary); + background: var(--glass-bg-strong); +} + +.tab-btn.active { + color: var(--bg-primary); + background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft)); + font-weight: 600; +} + +.header-controls { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.control-btn { + padding: 0.35rem 0.8rem; + font-size: 0.8rem; + white-space: nowrap; + border: 1px solid var(--glass-border); + border-radius: 999px; + color: var(--text-secondary); + transition: color 0.2s, border-color 0.2s; +} + +.control-btn:hover { + color: var(--text-primary); + border-color: var(--accent-gold); +} + +.lang-switch { + display: flex; + border: 1px solid var(--glass-border); + border-radius: 999px; + overflow: hidden; +} + +.lang-btn { + padding: 0.35rem 0.7rem; + font-size: 0.8rem; + color: var(--text-secondary); + transition: color 0.2s, background-color 0.2s; +} + +.lang-btn.active { + color: var(--bg-primary); + background: var(--accent-gold); + font-weight: 600; +} + +/* ------------------------------------------------------------ layout */ + +.panel { + padding-block: 1.25rem 2.5rem; +} + +.section-title { + margin: 2rem 0 1rem; + font-size: 1.15rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.placeholder { + padding: 2.5rem 1.5rem; + text-align: center; + color: var(--text-secondary); + font-style: italic; +} + +.site-footer { + padding: 1.5rem 1rem 2rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.85rem; +} + +/* -------------------------------------------------------------- hero */ + +.hero { + padding: clamp(1.5rem, 4vw, 3rem); + text-align: center; +} + +.hero-label { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin: 0 0 1.5rem; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.8rem; + color: var(--accent-gold); + font-weight: 600; +} + +.hero-phase { + color: var(--text-secondary); + font-weight: 400; +} + +.live-badge { + color: var(--live); +} + +.hero-matchup { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: clamp(0.75rem, 3vw, 2rem); + margin-bottom: 1.25rem; +} + +.hero-team { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +.flag { + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); +} + +.flag-lg { + width: clamp(48px, 8vw, 72px); + height: auto; +} + +.hero-team-name { + font-size: clamp(1rem, 2.5vw, 1.4rem); + font-weight: 600; +} + +.hero-vs { + font-size: clamp(1rem, 2vw, 1.3rem); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.hero-score { + font-size: clamp(2rem, 6vw, 3.2rem); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.hero-score-sep { + color: var(--accent-gold); + margin-inline: 0.35rem; +} + +.hero-meta { + margin: 0; + color: var(--text-secondary); + font-size: 0.95rem; +} + +.hero-kickoff { + margin: 0; + font-size: 1.3rem; + font-weight: 700; + color: var(--accent-gold); +} + +/* --------------------------------------------------------- countdown */ + +.countdown { + display: flex; + justify-content: center; + gap: clamp(0.5rem, 2vw, 1rem); + margin-top: 1.75rem; +} + +.count-box { + display: flex; + flex-direction: column; + align-items: center; + min-width: clamp(60px, 10vw, 84px); + padding: 0.7rem 0.5rem; + background: var(--glass-bg-strong); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); +} + +.count-value { + font-size: clamp(1.4rem, 4vw, 2rem); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.count-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +/* --------------------------------------------------------- dashboard */ + +.dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 1.4rem 1rem; + transition: transform 0.25s, border-color 0.25s; +} + +.stat-card:hover { + transform: translateY(-4px); + border-color: rgba(212, 175, 55, 0.45); +} + +.stat-icon { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + color: var(--accent-gold); + background: linear-gradient(135deg, rgba(212, 175, 55, 0.18), rgba(30, 136, 229, 0.18)); +} + +.stat-icon svg { + width: 24px; + height: 24px; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-size: 0.82rem; + color: var(--text-secondary); + text-align: center; +} + +/* ------------------------------------------------------ schedule */ + +.schedule-toolbar { + display: grid; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; +} + +.schedule-search { + width: 100%; + padding: 0.65rem 1.1rem; + border-radius: 999px; + border: 1px solid var(--glass-border); + background: rgba(8, 20, 33, 0.6); + color: var(--text-primary); + font: inherit; +} + +.schedule-search::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.filter-control { + flex: 1 1 150px; + min-width: 0; + padding: 0.5rem 0.65rem; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + background: rgba(8, 20, 33, 0.6); + color: var(--text-primary); + font-family: inherit; + font-size: 0.85rem; +} + +.sort-btn { + flex: 0 1 110px; + cursor: pointer; + transition: border-color 0.2s; +} + +.sort-btn:hover { + border-color: var(--accent-gold); +} + +.schedule-count { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 0 0 1rem; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.link-btn { + color: var(--accent-blue); + font-size: 0.85rem; +} + +.btn-primary { + padding: 0.55rem 1.3rem; + border-radius: 999px; + background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft)); + color: var(--bg-primary); + font-weight: 600; + transition: filter 0.2s; +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.link-btn:hover { + text-decoration: underline; +} + +.match-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +.match-card { + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1rem 1.1rem; + /* dozens of cards on screen — backdrop blur here is a big paint cost for + no visible gain over the smooth fixed gradient behind them */ + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(16, 36, 59, 0.55); +} + +.match-card-top { + display: flex; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.match-phase { + color: var(--accent-gold); + font-weight: 600; +} + +.match-status.live { + color: var(--live); + font-weight: 600; +} + +.match-status.finished { + color: var(--text-secondary); +} + +.match-teams { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.5rem; +} + +.match-team { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + min-width: 0; + text-align: center; +} + +.match-team-name { + font-size: 0.88rem; + font-weight: 600; +} + +.match-team-name.tbd { + font-style: italic; + font-weight: 400; + color: var(--text-secondary); +} + +.match-score { + font-size: 1.45rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.match-score-sep { + color: var(--accent-gold); + margin-inline: 0.2rem; +} + +.match-pens { + display: block; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-secondary); +} + +.match-vs { + color: var(--text-secondary); + text-transform: uppercase; + font-size: 0.8rem; +} + +.match-meta { + font-size: 0.78rem; + color: var(--text-secondary); +} + +/* ------------------------------------------------------ groups */ + +.standings-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + margin: 0 0 1rem; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.legend-dot.qualified { + background: var(--accent-blue); +} + +.legend-dot.third { + background: var(--accent-gold); +} + +.groups-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); +} + +.group-card { + padding: 1rem 1.1rem 1.2rem; + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(16, 36, 59, 0.55); +} + +.group-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.6rem; +} + +.group-card-header h3 { + margin: 0; + font-size: 1rem; + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.group-progress { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + border: 1px solid var(--glass-border); + border-radius: 999px; + padding: 0.15rem 0.6rem; +} + +.standings-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.standings-table th { + font-weight: 600; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.standings-table th, +.standings-table td { + padding: 0.34rem 0.3rem; + text-align: center; +} + +.standings-table .col-team { + text-align: left; + width: 99%; +} + +.standings-table td.col-team { + display: flex; + align-items: center; + gap: 0.45rem; + white-space: nowrap; +} + +.standings-table tbody tr { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.standings-table .row-qualified { + background: rgba(30, 136, 229, 0.12); +} + +.standings-table .row-third { + background: rgba(212, 175, 55, 0.09); +} + +.standings-table .col-pts { + font-weight: 700; +} + +@media (max-width: 420px) { + .standings-table .col-goals { + display: none; + } +} + +/* ------------------------------------------------------ stadiums */ + +.stadiums-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +.stadium-card { + overflow: hidden; + padding: 0; + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(16, 36, 59, 0.55); +} + +.stadium-img { + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + object-fit: cover; + border-bottom: 1px solid var(--glass-border); +} + +.stadium-body { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.9rem 1.1rem 1.1rem; +} + +.stadium-name { + margin: 0; + font-size: 1rem; +} + +.stadium-city { + margin: 0; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.stadium-stats { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0.35rem 0 0.5rem; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.stadium-stats strong { + color: var(--text-primary); +} + +.stadium-match-count { + border: 1px solid var(--glass-border); + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-size: 0.72rem; + white-space: nowrap; +} + +.stadium-card .link-btn { + align-self: flex-start; + padding: 0; +} + +/* ------------------------------------------------------ modal */ + +.match-card { + cursor: pointer; +} + +dialog.match-modal { + width: min(560px, calc(100% - 2rem)); + padding: 0; + border: 1px solid var(--glass-border); + border-radius: var(--radius); + background: linear-gradient(170deg, #122a45, var(--bg-primary) 70%); + color: var(--text-primary); + box-shadow: var(--shadow-soft); +} + +dialog.match-modal::backdrop { + background: rgba(4, 10, 18, 0.7); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.modal-content { + padding: 1.4rem 1.5rem 1.6rem; +} + +.modal-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1.1rem; +} + +.modal-phase { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent-gold); +} + +.modal-close { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid var(--glass-border); + border-radius: 50%; + color: var(--text-secondary); + transition: color 0.2s, border-color 0.2s; +} + +.modal-close:hover { + color: var(--text-primary); + border-color: var(--accent-gold); +} + +.modal-matchup { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.modal-team-name { + font-size: 1rem; + font-weight: 600; + text-align: center; +} + +.modal-score { + font-size: 2.2rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.modal-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin: 0 0 1.25rem; + padding: 1rem; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.04); +} + +.modal-info dt { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); + margin-bottom: 0.2rem; +} + +.modal-info dd { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.modal-stats h3 { + margin: 0 0 0.6rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); +} + +.modal-stat-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + padding: 0.4rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; +} + +.modal-stat-row span:first-child { + text-align: left; +} + +.modal-stat-row span:last-child { + text-align: right; +} + +.modal-stat-label { + color: var(--text-secondary); + font-size: 0.82rem; +} + +.modal-stats-note { + margin: 0.75rem 0 0; + font-size: 0.75rem; + font-style: italic; + color: var(--text-secondary); +} + +/* ------------------------------------------------------ favorites */ + +.fav-btn { + padding: 0 0.2rem; + font-size: 1em; + line-height: 1; + color: var(--text-secondary); + transition: color 0.2s, transform 0.2s; +} + +.fav-btn:hover { + color: var(--accent-gold); + transform: scale(1.2); +} + +.fav-btn.active { + color: var(--accent-gold); +} + +.match-card.has-fav { + border-left: 3px solid var(--accent-gold); +} + +.standings-table .fav-row td.col-team span:first-of-type { + color: var(--accent-gold); +} + +.fav-filter.active { + border-color: var(--accent-gold); + background: rgba(212, 175, 55, 0.16); + color: var(--accent-gold); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + margin-top: 1.25rem; +} + +/* ------------------------------------------------------- breakpoints */ + +/* mobile ≤767px: tighter spacing, stacked toolbar already handled by wrap */ +@media (max-width: 767px) { + .panel { + padding-block: 0.9rem 2rem; + } + + .hero { + padding: 1.25rem 1rem 1.5rem; + } + + .section-title { + margin-top: 1.4rem; + } +} + +/* tablet 768–1439px: single-row header with centered (reduced) menu */ +@media (min-width: 768px) { + .tabs { + flex: 0 1 auto; + order: 0; + margin-inline: auto; + } +} + +/* desktop 1440px+: full-width layout */ +@media (min-width: 1440px) { + .container { + width: min(1360px, 100% - 4rem); + } + + .hero-team-name { + font-size: 1.5rem; + } +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..99198b3 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,294 @@ +// app.js — entry point: loadData() over data/*.json, tab routing with lastTab +// persistence, formatMatchTime(), hero (live or next match + countdown), +// dashboard cards. + +import { getPrefs, setPref, toggleFavorite } from './storage.js'; +import { initI18n, setLang, getLang, getLocale, t, translatePhase } from './i18n.js'; +import { initSchedule } from './schedule.js'; +import { initGroups } from './groups.js'; +import { initStadiums } from './stadiums.js'; +import { initModal } from './modal.js'; +import { initBracket } from './bracket.js'; + +// ---------------------------------------------------------------- data + +let data = null; + +export async function loadData() { + if (data) return data; + const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config']; + const [teams, groups, matches, results, stadiums, bracketConfig] = await Promise.all( + files.map(async (name) => { + const res = await fetch(`data/${name}.json`); + if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`); + return res.json(); + }), + ); + data = { + teams, groups, matches, results, stadiums, bracketConfig, + teamById: new Map(teams.map((team) => [team.id, team])), + stadiumByName: new Map(stadiums.map((s) => [s.name, s])), + resultByMatchId: new Map(results.map((r) => [r.matchId, r])), + }; + return data; +} + +export function getData() { + return data; +} + +// ---------------------------------------------------------------- time + +export function matchDateUTC(match) { + return new Date(`${match.date}T${match.time}:00Z`); +} + +export function formatMatchTime(match, stadium, mode = getPrefs().timeMode ?? 'local') { + const options = { dateStyle: 'medium', timeStyle: 'short' }; + if (mode === 'stadium' && stadium?.timezone) options.timeZone = stadium.timezone; + return new Intl.DateTimeFormat(getLocale(), options).format(matchDateUTC(match)); +} + +export function flagSrc(team) { + return `assets/images/${team.flag}`; +} + +// ---------------------------------------------------------------- tabs + +const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums']; + +function activateTab(id, { updateHash = true } = {}) { + const tab = TABS.includes(id) ? id : 'home'; + for (const btn of document.querySelectorAll('.tab-btn')) { + const active = btn.dataset.tab === tab; + btn.classList.toggle('active', active); + btn.setAttribute('aria-selected', String(active)); + btn.setAttribute('tabindex', active ? '0' : '-1'); + } + for (const panelId of TABS) { + document.getElementById(`panel-${panelId}`).hidden = panelId !== tab; + } + setPref('lastTab', tab); + if (updateHash) history.replaceState(null, '', `#${tab}`); +} + +// programmatic navigation for cross-view links (e.g. stadium → its matches) +export function navigateTo(tab) { + activateTab(tab); + window.scrollTo({ top: 0 }); +} + +function initTabs() { + for (const btn of document.querySelectorAll('.tab-btn')) { + btn.addEventListener('click', () => activateTab(btn.dataset.tab)); + } + // roving tabindex + arrow keys per the WAI-ARIA tabs pattern + document.querySelector('.tabs').addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const buttons = [...document.querySelectorAll('.tab-btn')]; + const current = buttons.findIndex((b) => b.classList.contains('active')); + const next = + event.key === 'ArrowLeft' ? (current - 1 + buttons.length) % buttons.length + : event.key === 'ArrowRight' ? (current + 1) % buttons.length + : event.key === 'Home' ? 0 + : buttons.length - 1; + activateTab(buttons[next].dataset.tab); + buttons[next].focus(); + }); + window.addEventListener('hashchange', () => + activateTab(location.hash.slice(1), { updateHash: false })); + activateTab(location.hash.slice(1) || getPrefs().lastTab || 'home'); +} + +// ---------------------------------------------------------------- hero + +let countdownTimer = null; + +function findFeaturedMatch() { + const { matches, resultByMatchId } = data; + const live = matches.find((m) => resultByMatchId.get(m.id)?.status === 'live'); + if (live) return live; + return matches + .filter((m) => (resultByMatchId.get(m.id)?.status ?? 'scheduled') === 'scheduled') + .sort((a, b) => matchDateUTC(a) - matchDateUTC(b))[0] ?? null; +} + +function heroTeamHTML(teamId) { + const team = data.teamById.get(teamId); + if (!team) return `
${t('app.tbd')}
`; + return ` +
+ + ${team.name} +
`; +} + +function renderHero() { + clearInterval(countdownTimer); + const root = document.getElementById('hero-content'); + const match = findFeaturedMatch(); + if (!match) { + root.innerHTML = ''; + return; + } + const result = data.resultByMatchId.get(match.id); + const stadium = data.stadiumByName.get(match.stadium); + const live = result?.status === 'live'; + + const center = live + ? `
${result.homeScore}${result.awayScore}
` + : `
${t('hero.vs')}
`; + + root.innerHTML = ` +

+ ${live ? `● ${t('hero.live')}` : t('hero.nextMatch')} + ${translatePhase(match.phase)} +

+
+ ${heroTeamHTML(match.homeTeam)} + ${center} + ${heroTeamHTML(match.awayTeam)} +
+

${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}

+ ${live ? '' : `
`} + `; + if (!live) startCountdown(matchDateUTC(match)); +} + +function startCountdown(target) { + const root = document.getElementById('countdown'); + const units = ['days', 'hours', 'minutes', 'seconds']; + root.innerHTML = units.map((unit) => ` +
+ 0 + ${t(`countdown.${unit}`)} +
`).join(''); + const values = Object.fromEntries( + units.map((unit) => [unit, root.querySelector(`[data-unit="${unit}"]`)]), + ); + + const tick = () => { + const diff = target - Date.now(); + if (diff <= 0) { + clearInterval(countdownTimer); + root.innerHTML = `

${t('hero.kickoff')}

`; + return; + } + const seconds = Math.floor(diff / 1000); + values.days.textContent = Math.floor(seconds / 86400); + values.hours.textContent = String(Math.floor((seconds % 86400) / 3600)).padStart(2, '0'); + values.minutes.textContent = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'); + values.seconds.textContent = String(seconds % 60).padStart(2, '0'); + }; + tick(); + countdownTimer = setInterval(tick, 1000); +} + +// ------------------------------------------------------------ dashboard + +const ICONS = { + ball: '', + check: '', + clock: '', + shield: '', +}; + +function renderDashboard() { + const { matches, teams, results } = data; + const finished = results.filter((r) => r.status === 'finished').length; + const scheduled = results.filter((r) => r.status === 'scheduled').length; + const cards = [ + { icon: ICONS.ball, value: matches.length, label: 'dash.total' }, + { icon: ICONS.check, value: finished, label: 'dash.completed' }, + { icon: ICONS.clock, value: scheduled, label: 'dash.upcoming' }, + { icon: ICONS.shield, value: teams.length, label: 'dash.teams' }, + ]; + document.getElementById('dashboard').innerHTML = cards.map((card) => ` +
+ ${card.icon} + ${card.value} + ${t(card.label)} +
`).join(''); +} + +// ---------------------------------------------------------------- init + +// global star delegation — stars exist in schedule, groups, and modal +function initFavorites() { + document.addEventListener('click', (event) => { + const btn = event.target.closest('.fav-btn'); + if (!btn) return; + event.stopPropagation(); + toggleFavorite(btn.dataset.fav); + document.dispatchEvent(new CustomEvent('favchange')); + }, true); +} + +function syncTimeToggle() { + const btn = document.getElementById('time-toggle'); + const mode = getPrefs().timeMode ?? 'local'; + btn.textContent = `🕐 ${t(mode === 'local' ? 'time.local' : 'time.stadium')}`; + btn.setAttribute('aria-pressed', String(mode === 'stadium')); +} + +function initTimeToggle() { + const btn = document.getElementById('time-toggle'); + btn.addEventListener('click', () => { + const next = (getPrefs().timeMode ?? 'local') === 'local' ? 'stadium' : 'local'; + setPref('timeMode', next); + syncTimeToggle(); + document.dispatchEvent(new CustomEvent('timemodechange')); + }); + document.addEventListener('langchange', syncTimeToggle); + syncTimeToggle(); +} + +function initLangSwitch() { + const buttons = document.querySelectorAll('.lang-btn'); + const sync = () => { + for (const btn of buttons) btn.classList.toggle('active', btn.dataset.lang === getLang()); + }; + for (const btn of buttons) { + btn.addEventListener('click', () => { + setLang(btn.dataset.lang); + sync(); + }); + } + sync(); +} + +function renderHome() { + renderHero(); + renderDashboard(); +} + +function showError(error) { + document.getElementById('hero-content').innerHTML = ` +

${t('app.error')}

+

${t('app.errorHint')}

+

${error.message}

`; +} + +async function init() { + initI18n(); + initTabs(); + initLangSwitch(); + initTimeToggle(); + initFavorites(); + document.addEventListener('langchange', renderHome); + document.addEventListener('timemodechange', renderHero); + try { + await loadData(); + renderHome(); + initModal(); + initSchedule(); + initGroups(); + initBracket(); + initStadiums(); + } catch (error) { + showError(error); + } +} + +init(); diff --git a/assets/js/bracket.js b/assets/js/bracket.js new file mode 100644 index 0000000..9f4c8c2 --- /dev/null +++ b/assets/js/bracket.js @@ -0,0 +1,782 @@ +// bracket.js — knockout bracket. R32 slots come from bracket-config.json +// (group positions resolved via computeStandings(), best-third slots via +// thirdPlaceAssignment); every later round is generated by sequential pairing +// of winners (nodes 0-1 → next 0, 2-3 → next 1, …). SF losers feed the +// third-place match. Unresolvable slots render as placeholders, unplayed +// matches as TBD. Interactions land in step 8, simulation in step 9. + +import { getData, flagSrc } from './app.js'; +import { get as storageGet, set as storageSet, getFavorites } from './storage.js'; +import { computeStandings, isGroupFinished } from './groups.js'; +import { t, translatePhase } from './i18n.js'; +import { openMatchModal } from './modal.js'; + +const ROUNDS = [ + { id: 'R32', phase: 'Round of 32', size: 16 }, + { id: 'R16', phase: 'Round of 16', size: 8 }, + { id: 'QF', phase: 'Quarterfinals', size: 4 }, + { id: 'SF', phase: 'Semifinals', size: 2 }, + { id: 'FINAL', phase: 'Final', size: 1 }, +]; + +// ---------------------------------------------------------------- tree + +let cachedTree = null; + +// results are static per page load; simulation (step 9) will call this +export function invalidateBracket() { + cachedTree = null; +} + +export function getBracketTree() { + if (!cachedTree) cachedTree = buildTree(); + return cachedTree; +} + +function buildTree() { + const { matches, resultByMatchId, bracketConfig } = getData(); + const simulation = storageGet('simulation', {}); + const standings = computeStandings(); + const matchByRef = new Map(matches.filter((m) => m.bracketRef).map((m) => [m.bracketRef, m])); + const nodesByRef = new Map(); + const rounds = []; + let previous = null; + + for (const round of ROUNDS) { + const nodes = []; + for (let i = 0; i < round.size; i += 1) { + const ref = round.id === 'FINAL' ? 'FINAL' : `${round.id}-${i + 1}`; + const node = { + ref, + round: round.id, + phase: round.phase, + match: matchByRef.get(ref) ?? null, + home: null, + away: null, + winner: null, + loser: null, + result: null, + }; + if (round.id === 'R32') { + const entry = bracketConfig.round32[i]; + node.home = configSlot(entry.home, standings, bracketConfig); + node.away = configSlot(entry.away, standings, bracketConfig); + } else { + node.home = previous[i * 2].winner ? { teamId: previous[i * 2].winner } : { ph: { kind: 'tbd' } }; + node.away = previous[i * 2 + 1].winner ? { teamId: previous[i * 2 + 1].winner } : { ph: { kind: 'tbd' } }; + } + decide(node, resultByMatchId); + applySimulation(node, simulation); + nodes.push(node); + nodesByRef.set(ref, node); + } + rounds.push({ ...round, nodes }); + previous = nodes; + } + + const [sf1, sf2] = rounds.find((r) => r.id === 'SF').nodes; + const third = { + ref: 'THIRD-PLACE', + round: 'THIRD', + phase: 'Third Place', + match: matchByRef.get('THIRD-PLACE') ?? null, + home: sf1.loser ? { teamId: sf1.loser } : { ph: { kind: 'tbd' } }, + away: sf2.loser ? { teamId: sf2.loser } : { ph: { kind: 'tbd' } }, + winner: null, + loser: null, + result: null, + }; + decide(third, resultByMatchId); + applySimulation(third, simulation); + nodesByRef.set('THIRD-PLACE', third); + + return { rounds, third, nodesByRef, champion: nodesByRef.get('FINAL').winner }; +} + +function configSlot(spec, standings, config) { + if (spec.type === 'third') { + const group = config.thirdPlaceAssignment?.[String(spec.slot)]; + if (group && isGroupFinished(group)) return { teamId: standings[group][2].teamId }; + return { ph: { kind: 'third', n: spec.slot } }; + } + if (isGroupFinished(spec.ref)) return { teamId: standings[spec.ref][spec.pos - 1].teamId }; + return { ph: { kind: spec.pos === 1 ? 'groupWinner' : 'groupRunnerUp', g: spec.ref } }; +} + +// real finished results only — the simulation overlay is applySimulation()'s +// job and always loses to a real result +function decide(node, resultByMatchId) { + const result = node.match ? resultByMatchId.get(node.match.id) : null; + node.result = result ?? null; + node.simulated = false; + node.simScore = null; + if (!result || result.status !== 'finished' || !node.home.teamId || !node.away.teamId) return; + let side = result.homeScore > result.awayScore ? 'home' + : result.homeScore < result.awayScore ? 'away' + : null; + if (!side && result.penalties) { + side = result.penalties.home > result.penalties.away ? 'home' : 'away'; + } + if (!side) return; + node.winner = node[side].teamId; + node.loser = node[side === 'home' ? 'away' : 'home'].teamId; +} + +function isSimulatable(node) { + const realLocked = node.result && node.result.status !== 'scheduled'; + return Boolean(node.match) && !realLocked && Boolean(node.home.teamId) && Boolean(node.away.teamId); +} + +// User simulation overlays matches without a real final result. Entries whose +// winner no longer matches either resolved team (stale picks after upstream +// changes) are silently ignored — same validation the prediction import uses. +function applySimulation(node, simulation) { + if (node.winner || !node.home.teamId || !node.away.teamId) return; + if (node.result?.status === 'finished') return; + const entry = simulation[node.ref]; + if (!entry || (entry.winner !== node.home.teamId && entry.winner !== node.away.teamId)) return; + node.winner = entry.winner; + node.loser = entry.winner === node.home.teamId ? node.away.teamId : node.home.teamId; + node.simulated = true; + const parsed = /^(\d+)-(\d+)$/.exec(entry.score ?? ''); + node.simScore = parsed ? { home: Number(parsed[1]), away: Number(parsed[2]) } : null; +} + +// ------------------------------------------------------ display helpers + +function slotDisplay(slot) { + if (slot?.teamId) { + const team = getData().teamById.get(slot.teamId); + return { team, label: team.name }; + } + const ph = slot?.ph ?? { kind: 'tbd' }; + switch (ph.kind) { + case 'groupWinner': return { team: null, label: t('bracket.groupWinner').replace('{g}', ph.g) }; + case 'groupRunnerUp': return { team: null, label: t('bracket.groupRunnerUp').replace('{g}', ph.g) }; + case 'third': return { team: null, label: t('bracket.bestThird').replace('{n}', ph.n) }; + default: return { team: null, label: t('app.tbd') }; + } +} + +// Shared by schedule.js and modal.js: given a match (group or knockout) or a +// bracketRef string, returns { home, away } as { team: Team|null, label }. +export function resolveBracketTeams(matchOrRef) { + const ref = typeof matchOrRef === 'string' ? matchOrRef : matchOrRef.bracketRef; + if (!ref) { + const { teamById } = getData(); + const make = (id) => { + const team = teamById.get(id) ?? null; + return { team, label: team?.name ?? t('app.tbd') }; + }; + return { home: make(matchOrRef.homeTeam), away: make(matchOrRef.awayTeam) }; + } + const node = getBracketTree().nodesByRef.get(ref); + if (!node) { + const tbd = { team: null, label: t('app.tbd') }; + return { home: tbd, away: tbd }; + } + return { home: slotDisplay(node.home), away: slotDisplay(node.away) }; +} + +// ------------------------------------------------ shared favorites helper + +// Matches involving a favorited team — shared by schedule.js and bracket.js. +// Uses resolved slots so knockout matches count once their teams are known. +export function getFavoriteMatches(matches, favorites) { + if (!favorites.length) return []; + const favSet = new Set(favorites); + return matches.filter((match) => { + const slots = resolveBracketTeams(match); + return favSet.has(slots.home.team?.id) || favSet.has(slots.away.team?.id); + }); +} + +// --------------------------------------------------- challenge (step 12) + +// For every knockout match with a real finished result, compares the actual +// winner with the user's simulated pick. Recalculated on every render — no +// persistence of its own. +export function calculateChallengeScore(simulation, results, bracketTree) { + const resultById = new Map(results.map((r) => [r.matchId, r])); + const byPhaseCount = {}; + let correct = 0; + let total = 0; + for (const node of bracketTree.nodesByRef.values()) { + if (!node.match || !node.home.teamId || !node.away.teamId) continue; + const result = resultById.get(node.match.id); + if (result?.status !== 'finished') continue; + let side = result.homeScore > result.awayScore ? 'home' + : result.homeScore < result.awayScore ? 'away' + : null; + if (!side && result.penalties) { + side = result.penalties.home > result.penalties.away ? 'home' : 'away'; + } + if (!side) continue; + const realWinner = node[side].teamId; + total += 1; + byPhaseCount[node.phase] ??= { correct: 0, total: 0 }; + byPhaseCount[node.phase].total += 1; + if (simulation[node.ref]?.winner === realWinner) { + correct += 1; + byPhaseCount[node.phase].correct += 1; + } + } + const byPhase = Object.fromEntries( + Object.entries(byPhaseCount).map(([phase, c]) => [phase, `${c.correct}/${c.total}`]), + ); + return { correct, total, byPhase }; +} + +// ------------------------------------------- share prediction (step 12) + +export function getShareableLink() { + const url = new URL(location.href); + url.searchParams.set('prediction', btoa(JSON.stringify(getSimulation()))); + return url.toString(); +} + +// Reads ?prediction= on load. Applies only if every key exists in the current +// bracket tree and the user confirms; invalid/incompatible data is silently +// ignored. The param is removed from the URL either way. +export function loadPredictionFromURL() { + const param = new URLSearchParams(location.search).get('prediction'); + if (!param) return; + const cleanURL = new URL(location.href); + cleanURL.searchParams.delete('prediction'); + history.replaceState(null, '', cleanURL); + try { + const data = JSON.parse(atob(param)); + if (typeof data !== 'object' || data === null || Array.isArray(data)) return; + const entries = Object.entries(data); + if (!entries.length) return; + const tree = getBracketTree(); + const valid = entries.every(([ref, entry]) => + tree.nodesByRef.has(ref) && entry && typeof entry.winner === 'string'); + if (!valid) return; + if (window.confirm(t('share.confirm'))) { + storageSet('simulation', data); + refreshAfterSimChange(); + } + } catch { + // malformed base64/JSON — ignore silently per spec + } +} + +// -------------------------------------------------------------- render + +export function initBracket() { + render(); + document.addEventListener('langchange', render); + document.addEventListener('favchange', render); + loadPredictionFromURL(); + + const root = document.getElementById('bracket-root'); + const activate = (el) => { + if (simMode) { + const node = getBracketTree().nodesByRef.get(el.dataset.ref); + if (node && isSimulatable(node)) { + openSimEditor(el.dataset.ref); + return; + } + } + openMatchModal(Number(el.dataset.matchId)); + }; + root.addEventListener('click', (event) => { + const node = event.target.closest('[data-match-id]'); + if (node) activate(node); + }); + root.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const node = event.target.closest('[data-match-id]'); + if (node) { + event.preventDefault(); + activate(node); + } + }); +} + +function challengeCardHTML(challenge) { + const phases = Object.entries(challenge.byPhase) + .map(([phase, score]) => `${translatePhase(phase)}: ${score}`) + .join(''); + const summary = t('challenge.correct') + .replace('{x}', challenge.correct) + .replace('{y}', challenge.total); + return ` +
+ ${t('challenge.title')} + ${summary} + ${phases} +
`; +} + +function render() { + const tree = getBracketTree(); + const simulation = getSimulation(); + const challenge = calculateChallengeScore(simulation, getData().results, tree); + const hasPicks = Object.keys(simulation).length > 0; + document.getElementById('bracket-root').innerHTML = ` + ${challenge.total ? challengeCardHTML(challenge) : ''} +
+
+ + + +
+
+ + + +
+
+ ${simMode ? `

${t('sim.hint')}

` : ''} +
+
+
+ ${tree.rounds.filter((r) => r.id !== 'FINAL').map(roundColumnHTML).join('')} + ${finalColumnHTML(tree)} +
+
+
`; + document.getElementById('sim-toggle').addEventListener('click', () => { + simMode = !simMode; + render(); + }); + document.getElementById('sim-reset').addEventListener('click', () => { + storageSet('simulation', {}); + refreshAfterSimChange(); + }); + document.getElementById('share-pred').addEventListener('click', async (event) => { + const btn = event.currentTarget; + const link = getShareableLink(); + try { + await navigator.clipboard.writeText(link); + const original = btn.textContent; + btn.textContent = t('share.copied'); + setTimeout(() => { btn.textContent = original; }, 2000); + } catch { + window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually + } + }); + initInteractions(); +} + +function roundColumnHTML(round) { + const pairs = []; + for (let i = 0; i < round.nodes.length; i += 2) { + pairs.push(` +
+
${matchNodeHTML(round.nodes[i])}
+
${matchNodeHTML(round.nodes[i + 1])}
+
`); + } + return ` +
+

${translatePhase(round.phase)}

+
${pairs.join('')}
+
`; +} + +function finalColumnHTML(tree) { + const finalNode = tree.rounds.find((r) => r.id === 'FINAL').nodes[0]; + const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null; + return ` +
+

${translatePhase('Final')}

+
+
+
+ + ${t('bracket.champion')} + ${champion ? champion.label : t('app.tbd')} +
+
+
${matchNodeHTML(finalNode)}
+
+
+

${translatePhase('Third Place')}

+ ${matchNodeHTML(tree.third)} +
+
+
+
`; +} + +function matchNodeHTML(node) { + const home = slotDisplay(node.home); + const away = slotDisplay(node.away); + const live = node.result?.status === 'live'; + const favorites = getFavorites(); + const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId); + const classes = [ + 'bracket-match', + live ? 'is-live' : '', + node.simulated ? 'is-sim' : '', + hasFav ? 'has-fav' : '', + simMode && isSimulatable(node) ? 'simulatable' : '', + ].filter(Boolean).join(' '); + const interactive = node.match + ? `data-match-id="${node.match.id}" tabindex="0" role="button" + aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"` + : ''; + return ` +
+ ${node.simulated ? `${t('sim.chip')}` : ''} + ${teamRowHTML(home, node, 'home')} + ${teamRowHTML(away, node, 'away')} +
`; +} + +// ----------------------------------------------------- simulation (step 9) + +let simMode = false; + +function getSimulation() { + return storageGet('simulation', {}); +} + +function refreshAfterSimChange() { + invalidateBracket(); + render(); + document.dispatchEvent(new CustomEvent('simchange')); +} + +function ensureSimDialog() { + if (document.getElementById('sim-dialog')) return; + document.getElementById('modal-root').insertAdjacentHTML( + 'beforeend', + '', + ); + const dialog = document.getElementById('sim-dialog'); + dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close(); + }); +} + +function openSimEditor(ref) { + ensureSimDialog(); + const dialog = document.getElementById('sim-dialog'); + const node = getBracketTree().nodesByRef.get(ref); + const existing = getSimulation()[ref] ?? null; + const home = slotDisplay(node.home); + const away = slotDisplay(node.away); + const [scoreHome, scoreAway] = (existing?.score ?? '').split('-'); + let selected = existing?.winner ?? null; + + const teamButton = (slot, display) => ` + `; + + dialog.innerHTML = ` + `; + + const saveBtn = dialog.querySelector('#sim-save'); + const homeInput = dialog.querySelector('#sim-score-home'); + const awayInput = dialog.querySelector('#sim-score-away'); + + const select = (teamId) => { + selected = teamId; + for (const btn of dialog.querySelectorAll('.sim-team')) { + btn.classList.toggle('active', btn.dataset.team === teamId); + } + saveBtn.disabled = !selected; + }; + + for (const btn of dialog.querySelectorAll('.sim-team')) { + btn.addEventListener('click', () => select(btn.dataset.team)); + } + + // an unequal score implies the winner; a draw needs an explicit pick (pens) + const onScoreInput = () => { + const h = homeInput.value === '' ? null : Number(homeInput.value); + const a = awayInput.value === '' ? null : Number(awayInput.value); + if (h === null || a === null || h === a) return; + select(h > a ? node.home.teamId : node.away.teamId); + }; + homeInput.addEventListener('input', onScoreInput); + awayInput.addEventListener('input', onScoreInput); + + dialog.setAttribute('aria-label', `${t('sim.title')} — ${home.label} ${t('hero.vs')} ${away.label}`); + dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close()); + dialog.querySelector('#sim-clear').addEventListener('click', () => { + const simulation = getSimulation(); + delete simulation[ref]; + storageSet('simulation', simulation); + dialog.close(); + refreshAfterSimChange(); + }); + saveBtn.addEventListener('click', () => { + if (!selected) return; + const h = homeInput.value === '' ? (selected === node.home.teamId ? 1 : 0) : Number(homeInput.value); + const a = awayInput.value === '' ? (selected === node.away.teamId ? 1 : 0) : Number(awayInput.value); + const simulation = getSimulation(); + simulation[ref] = { winner: selected, score: `${h}-${a}` }; + storageSet('simulation', simulation); + dialog.close(); + refreshAfterSimChange(); + }); + + dialog.showModal(); +} + +// ------------------------------------------------- interactions (step 8) + +const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL']; +// zoom level survives re-renders (language switch) but not reloads +const view = { scale: 1, natW: 0, natH: 0 }; +const MIN_SCALE = 0.4; +const MAX_SCALE = 2; + +function initInteractions() { + const wrap = document.getElementById('bracket-wrap'); + const zoomBox = document.getElementById('bracket-zoom'); + const canvas = document.getElementById('bracket-canvas'); + view.natW = 0; // re-measure lazily — panel may be hidden right now (offsetWidth 0) + if (view.scale !== 1) applyScale(wrap, zoomBox, canvas); + updateZoomLabel(); + + const ensureMeasured = () => { + if (view.natW) return true; + if (!canvas.offsetWidth) return false; + view.natW = canvas.offsetWidth; + view.natH = canvas.offsetHeight; + return true; + }; + + const setScale = (next, cx, cy) => { + if (!ensureMeasured()) return; + const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)); + if (scale === view.scale) return; + const rect = wrap.getBoundingClientRect(); + const px = (cx - rect.left + wrap.scrollLeft) / view.scale; + const py = (cy - rect.top + wrap.scrollTop) / view.scale; + view.scale = scale; + applyScale(wrap, zoomBox, canvas); + wrap.scrollLeft = px * scale - (cx - rect.left); + wrap.scrollTop = py * scale - (cy - rect.top); + updateZoomLabel(); + }; + + const zoomAtCenter = (factor) => { + const rect = wrap.getBoundingClientRect(); + setScale(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2); + }; + + document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25)); + document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25)); + document.getElementById('zoom-reset').addEventListener('click', () => { + if (!ensureMeasured()) return; + view.scale = 1; + applyScale(wrap, zoomBox, canvas); + updateZoomLabel(); + }); + + wrap.addEventListener('wheel', (event) => { + event.preventDefault(); + setScale(view.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY); + }, { passive: false }); + + // drag to pan (1 pointer) + pinch to zoom (2 pointers) + const pointers = new Map(); + let dragging = false; + let dragMoved = false; + let start = null; + let pinch = null; + + wrap.addEventListener('pointerdown', (event) => { + pointers.set(event.pointerId, event); + // IMPORTANT: do NOT setPointerCapture here — capturing retargets the + // eventual click to the wrap, which kills match-node clicks (modal and + // simulation). Capture only once a real drag/pinch begins. + if (pointers.size === 1) { + dragging = true; + dragMoved = false; + start = { x: event.clientX, y: event.clientY, left: wrap.scrollLeft, top: wrap.scrollTop }; + } else if (pointers.size === 2) { + dragging = false; + for (const p of pointers.values()) { + try { wrap.setPointerCapture(p.pointerId); } catch { /* pointer already gone */ } + } + const [a, b] = [...pointers.values()]; + pinch = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: view.scale }; + } + }); + + wrap.addEventListener('pointermove', (event) => { + if (!pointers.has(event.pointerId)) return; + pointers.set(event.pointerId, event); + if (pointers.size === 2 && pinch) { + const [a, b] = [...pointers.values()]; + const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + setScale(pinch.scale * (dist / pinch.dist), (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2); + } else if (dragging) { + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + if (!dragMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { + dragMoved = true; + try { wrap.setPointerCapture(event.pointerId); } catch { /* pointer already gone */ } + wrap.classList.add('dragging'); + } + if (dragMoved) { + wrap.scrollLeft = start.left - dx; + wrap.scrollTop = start.top - dy; + } + } + }); + + const endPointer = (event) => { + pointers.delete(event.pointerId); + if (pointers.size < 2) pinch = null; + if (pointers.size === 0) { + dragging = false; + wrap.classList.remove('dragging'); + } + }; + wrap.addEventListener('pointerup', endPointer); + wrap.addEventListener('pointercancel', endPointer); + + // a real drag must not fire the match-node click underneath + wrap.addEventListener('click', (event) => { + if (dragMoved) { + event.stopPropagation(); + event.preventDefault(); + dragMoved = false; + } + }, true); + + // hover/focus path highlight + canvas.addEventListener('mouseover', (event) => { + const node = event.target.closest('.bracket-match'); + if (node) showPath(node.dataset.ref); + }); + canvas.addEventListener('mouseout', () => clearPath()); + canvas.addEventListener('focusin', (event) => { + const node = event.target.closest('.bracket-match'); + if (node) showPath(node.dataset.ref); + }); + canvas.addEventListener('focusout', () => clearPath()); +} + +function applyScale(wrap, zoomBox, canvas) { + if (view.natW) { + zoomBox.style.width = `${view.natW * view.scale}px`; + zoomBox.style.height = `${view.natH * view.scale}px`; + } + canvas.style.transform = view.scale === 1 ? '' : `scale(${view.scale})`; + if (view.scale === 1) { + zoomBox.style.width = ''; + zoomBox.style.height = ''; + } +} + +function updateZoomLabel() { + const label = document.getElementById('zoom-reset'); + if (label) label.textContent = `${Math.round(view.scale * 100)}%`; +} + +// ----------------------------------------------------- path highlight + +function refOf(roundIndex, nodeIndex) { + const id = ROUND_ORDER[roundIndex]; + return id === 'FINAL' ? 'FINAL' : `${id}-${nodeIndex + 1}`; +} + +// full path = the hovered node, every feeder below it, and its winner's +// route up to the final +function pathRefs(ref) { + const refs = new Set([ref]); + if (ref === 'THIRD-PLACE') { + refs.add('SF-1'); + refs.add('SF-2'); + return refs; + } + const [roundId, num] = ref === 'FINAL' ? ['FINAL', '1'] : ref.split('-'); + const r = ROUND_ORDER.indexOf(roundId); + if (r < 0) return refs; + let i = Number(num) - 1; + + for (let rr = r, ii = i; rr < ROUND_ORDER.length - 1; rr += 1) { + ii = Math.floor(ii / 2); + refs.add(refOf(rr + 1, ii)); + } + const stack = [[r, i]]; + while (stack.length) { + const [cr, ci] = stack.pop(); + if (cr === 0) continue; + for (const child of [ci * 2, ci * 2 + 1]) { + refs.add(refOf(cr - 1, child)); + stack.push([cr - 1, child]); + } + } + return refs; +} + +function showPath(ref) { + const canvas = document.getElementById('bracket-canvas'); + clearPath(); + canvas.classList.add('has-path'); + for (const r of pathRefs(ref)) { + const el = canvas.querySelector(`[data-ref="${r}"]`); + if (!el) continue; + el.classList.add('path-on'); + el.closest('.bracket-slot')?.classList.add('path-on'); + el.closest('.bracket-pair')?.classList.add('path-on'); + } +} + +function clearPath() { + const canvas = document.getElementById('bracket-canvas'); + canvas.classList.remove('has-path'); + for (const el of canvas.querySelectorAll('.path-on')) el.classList.remove('path-on'); +} + +function teamRowHTML(display, node, side) { + const { result } = node; + let score = ''; + if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) { + const goals = side === 'home' ? result.homeScore : result.awayScore; + const pens = result.penalties + ? `(${side === 'home' ? result.penalties.home : result.penalties.away})` + : ''; + score = `${goals}${pens}`; + } else if (node.simulated && node.simScore) { + score = `${node.simScore[side]}`; + } + const isWinner = node.winner !== null && node[side].teamId === node.winner; + const flag = display.team + ? `` + : ''; + return ` +
+ ${flag} + ${display.label} + ${score} +
`; +} diff --git a/assets/js/calendar.js b/assets/js/calendar.js new file mode 100644 index 0000000..e6a8811 --- /dev/null +++ b/assets/js/calendar.js @@ -0,0 +1,49 @@ +// calendar.js — "Add to calendar" export (RFC 5545). One VEVENT per match, +// DTSTART/DTEND in UTC with a fixed 2h duration, CRLF line endings (required +// by the spec — some calendar apps reject \n). Knockout team names come from +// resolveBracketTeams(). + +import { matchDateUTC } from './app.js'; +import { resolveBracketTeams } from './bracket.js'; +import { translatePhase } from './i18n.js'; + +// Date → 20260611T160000Z +function icsDate(date) { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +// RFC 5545 TEXT escaping: backslash, comma, semicolon, newline +function icsEscape(text) { + return String(text).replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n'); +} + +export function exportMatchToICS(match, stadium) { + const start = matchDateUTC(match); + const end = new Date(start.getTime() + 2 * 60 * 60 * 1000); + const { home, away } = resolveBracketTeams(match); + const location = stadium ? `${stadium.name}, ${stadium.city}` : match.city; + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//WorldCup2026Hub//EN', + 'BEGIN:VEVENT', + `UID:match-${match.id}@worldcup2026hub`, + `DTSTAMP:${icsDate(start)}`, + `DTSTART:${icsDate(start)}`, + `DTEND:${icsDate(end)}`, + `SUMMARY:${icsEscape(`${home.label} x ${away.label} — ${translatePhase(match.phase)}`)}`, + `LOCATION:${icsEscape(location)}`, + 'END:VEVENT', + 'END:VCALENDAR', + ]; + + const blob = new Blob([lines.join('\r\n') + '\r\n'], { type: 'text/calendar' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `match-${match.id}.ics`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(link.href); +} diff --git a/assets/js/groups.js b/assets/js/groups.js new file mode 100644 index 0000000..f1810ad --- /dev/null +++ b/assets/js/groups.js @@ -0,0 +1,121 @@ +// groups.js — standings computed from groups.json + results.json and the 12 +// group tables. Only matches with status "finished" count toward standings +// (live scores are ignored until full-time). computeStandings() and +// isGroupFinished() are reused by bracket.js to resolve the Round of 32. + +import { getData, flagSrc } from './app.js'; +import { t } from './i18n.js'; +import { getFavorites } from './storage.js'; + +// Tiebreak order per complement spec §2: points, goal difference, goals for. +// Team id alphabetical as a final stable fallback. +export function computeStandings() { + const { groups, matches, resultByMatchId } = getData(); + const tables = {}; + for (const [letter, teamIds] of Object.entries(groups)) { + tables[letter] = new Map(teamIds.map((id) => [id, { + teamId: id, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, gd: 0, points: 0, + }])); + } + + for (const match of matches) { + if (!match.phase.startsWith('Group ')) continue; + const result = resultByMatchId.get(match.id); + if (result?.status !== 'finished') continue; + const rows = tables[match.phase.slice(6)]; + applyResult(rows.get(match.homeTeam), result.homeScore, result.awayScore); + applyResult(rows.get(match.awayTeam), result.awayScore, result.homeScore); + } + + const standings = {}; + for (const [letter, rows] of Object.entries(tables)) { + standings[letter] = [...rows.values()].sort((a, b) => + b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId)); + } + return standings; +} + +function applyResult(row, scored, conceded) { + row.played += 1; + row.gf += scored; + row.ga += conceded; + row.gd = row.gf - row.ga; + if (scored > conceded) { row.won += 1; row.points += 3; } + else if (scored === conceded) { row.drawn += 1; row.points += 1; } + else { row.lost += 1; } +} + +export function isGroupFinished(letter) { + const { matches, resultByMatchId } = getData(); + return matches + .filter((m) => m.phase === `Group ${letter}`) + .every((m) => resultByMatchId.get(m.id)?.status === 'finished'); +} + +// -------------------------------------------------------------- render + +export function initGroups() { + render(); + document.addEventListener('langchange', render); + document.addEventListener('favchange', render); +} + +function render() { + const standings = computeStandings(); + document.getElementById('groups-root').innerHTML = ` +

+ ${t('standings.legendTop2')} + ${t('standings.legendThird')} +

+
+ ${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')} +
`; +} + +function groupCardHTML(letter, rows) { + const finished = isGroupFinished(letter); + const headers = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts'] + .map((key) => `${t(`standings.${key}`)}`) + .join(''); + + return ` +
+
+

${t('phase.group')} ${letter}

+ ${finished ? '' : `${t('standings.inProgress')}`} +
+ + + ${headers} + + ${rows.map(standingRowHTML).join('')} +
#${t('standings.team')}
+
`; +} + +function standingRowHTML(row, index) { + const team = getData().teamById.get(row.teamId); + const fav = getFavorites().includes(team.id); + const rankClass = [ + index < 2 ? 'row-qualified' : index === 2 ? 'row-third' : '', + fav ? 'fav-row' : '', + ].filter(Boolean).join(' '); + return ` + + ${index + 1} + + + ${team.name} + + + ${row.played} + ${row.won} + ${row.drawn} + ${row.lost} + ${row.gf} + ${row.ga} + ${row.gd > 0 ? '+' : ''}${row.gd} + ${row.points} + `; +} diff --git a/assets/js/i18n.js b/assets/js/i18n.js new file mode 100644 index 0000000..6dc77c5 --- /dev/null +++ b/assets/js/i18n.js @@ -0,0 +1,269 @@ +// i18n.js — EN/PT-BR dictionaries + t(key). Every UI string in the app goes +// through t(); static HTML uses data-i18n / data-i18n-aria attributes. +// Language persists in wc2026_prefs.lang; changing it dispatches "langchange" +// so modules can re-render dynamic content. + +import { getPrefs, setPref } from './storage.js'; + +const dicts = { + en: { + 'a11y.skip': 'Skip to content', + 'a11y.mainNav': 'Main navigation', + 'a11y.langSwitch': 'Switch language', + 'nav.home': 'Home', + 'nav.matches': 'Matches', + 'nav.groups': 'Groups', + 'nav.bracket': 'Knockout', + 'nav.stadiums': 'Stadiums', + 'hero.live': 'Live', + 'hero.nextMatch': 'Next match', + 'hero.kickoff': 'Kickoff!', + 'hero.countdownLabel': 'Time until kickoff', + 'hero.vs': 'vs', + 'countdown.days': 'days', + 'countdown.hours': 'hours', + 'countdown.minutes': 'min', + 'countdown.seconds': 'sec', + 'dash.title': 'Tournament overview', + 'dash.total': 'Total matches', + 'dash.completed': 'Completed', + 'dash.upcoming': 'Upcoming', + 'dash.teams': 'Teams', + 'app.loading': 'Loading data…', + 'app.error': 'Could not load tournament data.', + 'app.errorHint': 'If you opened index.html directly from disk, serve the folder instead: python -m http.server', + 'app.comingSoon': 'This section arrives in a later build step.', + 'app.tbd': 'TBD', + 'phase.group': 'Group', + 'phase.r32': 'Round of 32', + 'phase.r16': 'Round of 16', + 'phase.qf': 'Quarterfinals', + 'phase.sf': 'Semifinals', + 'phase.third': 'Third Place', + 'phase.final': 'Final', + 'schedule.searchPlaceholder': 'Search team, city or stadium…', + 'schedule.dateFilter': 'Filter by date', + 'schedule.allGroups': 'All groups', + 'schedule.allPhases': 'All phases', + 'schedule.groupStage': 'Group stage', + 'schedule.allTeams': 'All teams', + 'schedule.allStadiums': 'All stadiums', + 'schedule.sortAsc': 'Date ↑', + 'schedule.sortDesc': 'Date ↓', + 'schedule.match': 'match', + 'schedule.matches': 'matches', + 'schedule.noResults': 'No matches found — adjust the filters.', + 'schedule.clear': 'Clear filters', + 'status.finished': 'Full-time', + 'status.pens': 'pens', + 'standings.team': 'Team', + 'standings.played': 'P', + 'standings.won': 'W', + 'standings.drawn': 'D', + 'standings.lost': 'L', + 'standings.gf': 'GF', + 'standings.ga': 'GA', + 'standings.gd': 'GD', + 'standings.pts': 'Pts', + 'standings.legendTop2': 'Advance to the Round of 32', + 'standings.legendThird': 'In contention for best third place', + 'standings.inProgress': 'In progress', + 'stadiums.capacity': 'Capacity', + 'stadiums.viewMatches': 'View matches', + 'status.scheduled': 'Scheduled', + 'modal.close': 'Close', + 'modal.date': 'Date & time', + 'modal.stadium': 'Stadium', + 'modal.city': 'City', + 'modal.stats': 'Match stats', + 'modal.possession': 'Possession', + 'modal.shots': 'Shots', + 'modal.cards': 'Cards', + 'modal.statsSoon': 'Detailed stats will appear here once available.', + 'bracket.groupWinner': 'Group {g} Winner', + 'bracket.groupRunnerUp': 'Group {g} Runner-up', + 'bracket.bestThird': 'Best 3rd #{n}', + 'bracket.champion': 'Champion', + 'bracket.zoomIn': 'Zoom in', + 'bracket.zoomOut': 'Zoom out', + 'bracket.zoomReset': 'Reset zoom', + 'sim.mode': 'Simulation', + 'sim.reset': 'Reset picks', + 'sim.hint': 'Simulation on — click a highlighted match to pick its winner. Real results are never changed.', + 'sim.title': 'Simulate', + 'sim.pickWinner': 'Pick the winner. Equal or empty score means penalties.', + 'sim.save': 'Save pick', + 'sim.clear': 'Remove pick', + 'sim.chip': 'SIM', + 'time.local': 'Local time', + 'time.stadium': 'Stadium time', + 'time.toggleAria': 'Toggle between local and stadium time', + 'schedule.myMatches': 'My matches', + 'fav.toggle': 'Favorite', + 'challenge.title': 'Bracket challenge', + 'challenge.correct': '{x} of {y} picks correct', + 'share.button': 'Share prediction', + 'share.copied': 'Link copied!', + 'share.confirm': 'Apply the shared prediction? Your current picks will be replaced.', + 'modal.addCalendar': 'Add to calendar', + 'footer.note': 'Fan-made static hub — all data lives in JSON files.', + }, + pt: { + 'a11y.skip': 'Pular para o conteúdo', + 'a11y.mainNav': 'Navegação principal', + 'a11y.langSwitch': 'Trocar idioma', + 'nav.home': 'Início', + 'nav.matches': 'Partidas', + 'nav.groups': 'Grupos', + 'nav.bracket': 'Mata-mata', + 'nav.stadiums': 'Estádios', + 'hero.live': 'Ao vivo', + 'hero.nextMatch': 'Próxima partida', + 'hero.kickoff': 'Bola rolando!', + 'hero.countdownLabel': 'Tempo até o início da partida', + 'hero.vs': 'vs', + 'countdown.days': 'dias', + 'countdown.hours': 'horas', + 'countdown.minutes': 'min', + 'countdown.seconds': 'seg', + 'dash.title': 'Visão geral do torneio', + 'dash.total': 'Total de partidas', + 'dash.completed': 'Encerradas', + 'dash.upcoming': 'Próximas', + 'dash.teams': 'Seleções', + 'app.loading': 'Carregando dados…', + 'app.error': 'Não foi possível carregar os dados do torneio.', + 'app.errorHint': 'Se você abriu o index.html direto do disco, sirva a pasta: python -m http.server', + 'app.comingSoon': 'Esta seção chega em uma próxima etapa.', + 'app.tbd': 'A definir', + 'phase.group': 'Grupo', + 'phase.r32': '16 avos de final', + 'phase.r16': 'Oitavas de final', + 'phase.qf': 'Quartas de final', + 'phase.sf': 'Semifinais', + 'phase.third': 'Disputa de 3º lugar', + 'phase.final': 'Final', + 'schedule.searchPlaceholder': 'Buscar seleção, cidade ou estádio…', + 'schedule.dateFilter': 'Filtrar por data', + 'schedule.allGroups': 'Todos os grupos', + 'schedule.allPhases': 'Todas as fases', + 'schedule.groupStage': 'Fase de grupos', + 'schedule.allTeams': 'Todas as seleções', + 'schedule.allStadiums': 'Todos os estádios', + 'schedule.sortAsc': 'Data ↑', + 'schedule.sortDesc': 'Data ↓', + 'schedule.match': 'partida', + 'schedule.matches': 'partidas', + 'schedule.noResults': 'Nenhuma partida encontrada — ajuste os filtros.', + 'schedule.clear': 'Limpar filtros', + 'status.finished': 'Encerrado', + 'status.pens': 'pên.', + 'standings.team': 'Seleção', + 'standings.played': 'J', + 'standings.won': 'V', + 'standings.drawn': 'E', + 'standings.lost': 'D', + 'standings.gf': 'GP', + 'standings.ga': 'GC', + 'standings.gd': 'SG', + 'standings.pts': 'Pts', + 'standings.legendTop2': 'Avançam aos 16 avos de final', + 'standings.legendThird': 'Na briga por melhor 3º lugar', + 'standings.inProgress': 'Em andamento', + 'stadiums.capacity': 'Capacidade', + 'stadiums.viewMatches': 'Ver partidas', + 'status.scheduled': 'Agendada', + 'modal.close': 'Fechar', + 'modal.date': 'Data e hora', + 'modal.stadium': 'Estádio', + 'modal.city': 'Cidade', + 'modal.stats': 'Estatísticas', + 'modal.possession': 'Posse de bola', + 'modal.shots': 'Finalizações', + 'modal.cards': 'Cartões', + 'modal.statsSoon': 'Estatísticas detalhadas aparecerão aqui quando disponíveis.', + 'bracket.groupWinner': '1º do Grupo {g}', + 'bracket.groupRunnerUp': '2º do Grupo {g}', + 'bracket.bestThird': 'Melhor 3º #{n}', + 'bracket.champion': 'Campeão', + 'bracket.zoomIn': 'Aproximar', + 'bracket.zoomOut': 'Afastar', + 'bracket.zoomReset': 'Restaurar zoom', + 'sim.mode': 'Simulação', + 'sim.reset': 'Limpar palpites', + 'sim.hint': 'Simulação ativa — clique numa partida destacada para escolher o vencedor. Resultados reais nunca mudam.', + 'sim.title': 'Simular', + 'sim.pickWinner': 'Escolha o vencedor. Placar igual ou vazio indica pênaltis.', + 'sim.save': 'Salvar palpite', + 'sim.clear': 'Remover palpite', + 'sim.chip': 'SIM', + 'time.local': 'Hora local', + 'time.stadium': 'Hora do estádio', + 'time.toggleAria': 'Alternar entre hora local e do estádio', + 'schedule.myMatches': 'Minhas partidas', + 'fav.toggle': 'Favoritar', + 'challenge.title': 'Bolão do mata-mata', + 'challenge.correct': '{x} de {y} palpites certos', + 'share.button': 'Compartilhar palpites', + 'share.copied': 'Link copiado!', + 'share.confirm': 'Aplicar os palpites compartilhados? Seus palpites atuais serão substituídos.', + 'modal.addCalendar': 'Adicionar à agenda', + 'footer.note': 'Hub estático feito por fãs — todos os dados vivem em arquivos JSON.', + }, +}; + +let lang = 'en'; + +export function initI18n() { + const saved = getPrefs().lang; + lang = saved ?? (navigator.language?.toLowerCase().startsWith('pt') ? 'pt' : 'en'); + document.documentElement.lang = getLocale(); + applyI18n(); +} + +export function t(key) { + return dicts[lang][key] ?? dicts.en[key] ?? key; +} + +export function getLang() { + return lang; +} + +export function getLocale() { + return lang === 'pt' ? 'pt-BR' : 'en-US'; +} + +export function setLang(next) { + if (next === lang || !dicts[next]) return; + lang = next; + setPref('lang', next); + document.documentElement.lang = getLocale(); + applyI18n(); + document.dispatchEvent(new CustomEvent('langchange')); +} + +export function applyI18n(root = document) { + for (const el of root.querySelectorAll('[data-i18n]')) { + el.textContent = t(el.dataset.i18n); + } + for (const el of root.querySelectorAll('[data-i18n-aria]')) { + el.setAttribute('aria-label', t(el.dataset.i18nAria)); + } +} + +// Phase labels come from matches.json in English ("Group A", "Round of 32"…); +// translate the known ones, pass anything else through untouched. +const PHASE_KEYS = { + 'Round of 32': 'phase.r32', + 'Round of 16': 'phase.r16', + Quarterfinals: 'phase.qf', + Semifinals: 'phase.sf', + 'Third Place': 'phase.third', + Final: 'phase.final', +}; + +export function translatePhase(phase) { + if (phase.startsWith('Group ')) return `${t('phase.group')} ${phase.slice(6)}`; + const key = PHASE_KEYS[phase]; + return key ? t(key) : phase; +} diff --git a/assets/js/modal.js b/assets/js/modal.js new file mode 100644 index 0000000..83d84ea --- /dev/null +++ b/assets/js/modal.js @@ -0,0 +1,126 @@ +// modal.js — match detail modal built on the native element +// (focus trap, Esc-to-close and ::backdrop for free). Shows teams, time, +// stadium, city, capacity, result (+penalties) and a placeholder section for +// future stats (possession, shots, cards). "Add to calendar" lands in step 12. + +import { getData, formatMatchTime, flagSrc } from './app.js'; +import { t, getLocale, translatePhase } from './i18n.js'; +import { resolveBracketTeams } from './bracket.js'; +import { getFavorites } from './storage.js'; +import { exportMatchToICS } from './calendar.js'; + +let dialog = null; +let currentMatchId = null; +let lastFocused = null; + +export function initModal() { + document.getElementById('modal-root').innerHTML = + ''; + dialog = document.getElementById('match-dialog'); + + // a click on the dialog element itself (not its padded content) = backdrop + dialog.addEventListener('click', (event) => { + if (event.target === dialog) dialog.close(); + }); + dialog.addEventListener('close', () => { + currentMatchId = null; + lastFocused?.focus?.(); + }); + const rerenderIfOpen = () => { + if (dialog.open && currentMatchId) renderContent(currentMatchId); + }; + document.addEventListener('langchange', rerenderIfOpen); + document.addEventListener('favchange', rerenderIfOpen); + document.addEventListener('timemodechange', rerenderIfOpen); +} + +export function openMatchModal(matchId) { + lastFocused = document.activeElement; + currentMatchId = matchId; + renderContent(matchId); + dialog.showModal(); +} + +// -------------------------------------------------------------- render + +function renderContent(matchId) { + const { matches, resultByMatchId, stadiumByName } = getData(); + const match = matches.find((m) => m.id === matchId); + if (!match) return; + const result = resultByMatchId.get(matchId); + const status = result?.status ?? 'scheduled'; + const stadium = stadiumByName.get(match.stadium); + const slots = resolveBracketTeams(match); + const numberFmt = new Intl.NumberFormat(getLocale()); + + const statusChip = + status === 'live' ? `● ${t('hero.live')}` + : status === 'finished' ? `${t('status.finished')}` + : `${t('status.scheduled')}`; + + let center; + if (status === 'finished' || status === 'live') { + const pens = result.penalties + ? `(${result.penalties.home}–${result.penalties.away} ${t('status.pens')})` + : ''; + center = ``; + } else { + center = `${t('hero.vs')}`; + } + + dialog.innerHTML = ` + `; + + dialog.setAttribute('aria-label', `${slots.home.label} ${t('hero.vs')} ${slots.away.label} — ${translatePhase(match.phase)}`); + dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close()); + dialog.querySelector('#modal-ics').addEventListener('click', () => exportMatchToICS(match, stadium)); +} + +function teamHTML(slot) { + if (!slot.team) return `
${slot.label}
`; + const fav = getFavorites().includes(slot.team.id); + return ` +
+ + ${slot.team.name} + + +
`; +} + +// placeholder row — real values will replace the dashes when stats data exists +function statRow(label) { + return ` + `; +} diff --git a/assets/js/schedule.js b/assets/js/schedule.js new file mode 100644 index 0000000..0819267 --- /dev/null +++ b/assets/js/schedule.js @@ -0,0 +1,239 @@ +// schedule.js — match schedule: list cards, filters (date, group, phase, +// team, stadium), diacritic-insensitive search, date sort. +// Knockout team names show as TBD until resolveBracketTeams() lands (step 7). + +import { getData, formatMatchTime, matchDateUTC, flagSrc } from './app.js'; +import { t, translatePhase } from './i18n.js'; +import { openMatchModal } from './modal.js'; +import { resolveBracketTeams, getFavoriteMatches } from './bracket.js'; +import { getFavorites } from './storage.js'; + +const KNOCKOUT_PHASES = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final']; + +const state = { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false }; + +export function initSchedule() { + renderToolbar(); + renderList(); + document.addEventListener('langchange', () => { + renderToolbar(); + renderList(); + }); + // simulated picks change resolved knockout teams shown on cards + document.addEventListener('simchange', renderList); + document.addEventListener('favchange', renderList); + document.addEventListener('timemodechange', renderList); + + // delegation on the panel root — survives every list re-render + const root = document.getElementById('schedule-root'); + root.addEventListener('click', (event) => { + if (event.target.closest('.fav-btn')) return; // star toggles, never opens + const card = event.target.closest('.match-card'); + if (card) openMatchModal(Number(card.dataset.matchId)); + }); + root.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + if (event.target.closest('.fav-btn')) return; + const card = event.target.closest('.match-card'); + if (card) { + event.preventDefault(); + openMatchModal(Number(card.dataset.matchId)); + } + }); +} + +// ------------------------------------------------------------- toolbar + +function renderToolbar() { + const { teams, groups, stadiums } = getData(); + const root = document.getElementById('schedule-root'); + + const teamOptions = [...teams] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((team) => ``).join(''); + const groupOptions = Object.keys(groups) + .map((letter) => ``).join(''); + const phaseOptions = KNOCKOUT_PHASES + .map((phase) => ``).join(''); + const stadiumOptions = stadiums + .map((s) => ``).join(''); + + root.innerHTML = ` +
+ +
+ + + + + + + +
+
+

+ + +

+
`; + + // restore current filter values after a rebuild (e.g. language change) + byId('sched-search').value = state.search; + byId('sched-date').value = state.date; + byId('sched-group').value = state.group; + byId('sched-phase').value = state.phase; + byId('sched-team').value = state.team; + byId('sched-stadium').value = state.stadium; + syncSortLabel(); + + byId('sched-search').addEventListener('input', (e) => setFilter('search', e.target.value)); + byId('sched-date').addEventListener('change', (e) => setFilter('date', e.target.value)); + byId('sched-group').addEventListener('change', (e) => setFilter('group', e.target.value)); + byId('sched-phase').addEventListener('change', (e) => setFilter('phase', e.target.value)); + byId('sched-team').addEventListener('change', (e) => setFilter('team', e.target.value)); + byId('sched-stadium').addEventListener('change', (e) => setFilter('stadium', e.target.value)); + byId('sched-sort').addEventListener('click', () => { + state.sort = state.sort === 'asc' ? 'desc' : 'asc'; + syncSortLabel(); + renderList(); + }); + byId('sched-fav').addEventListener('click', () => { + state.favOnly = !state.favOnly; + const btn = byId('sched-fav'); + btn.classList.toggle('active', state.favOnly); + btn.setAttribute('aria-pressed', String(state.favOnly)); + renderList(); + }); + byId('sched-clear').addEventListener('click', () => { + Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false }); + renderToolbar(); + renderList(); + }); +} + +function byId(id) { + return document.getElementById(id); +} + +// external entry point (stadiums page) — show only matches at one stadium +export function setStadiumFilter(stadiumName) { + Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: stadiumName, favOnly: false }); + renderToolbar(); + renderList(); +} + +function setFilter(key, value) { + state[key] = value; + renderList(); +} + +function syncSortLabel() { + byId('sched-sort').textContent = t(state.sort === 'asc' ? 'schedule.sortAsc' : 'schedule.sortDesc'); +} + +// ------------------------------------------------------------ filtering + +function normalize(text) { + return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); +} + +function matchesFilters(match) { + if (state.date && match.date !== state.date) return false; + if (state.group && match.phase !== `Group ${state.group}`) return false; + if (state.phase === 'groups' && !match.phase.startsWith('Group')) return false; + if (state.phase && state.phase !== 'groups' && match.phase !== state.phase) return false; + if (state.stadium && match.stadium !== state.stadium) return false; + if (state.team || state.search) { + // resolved teams, so knockout matches are searchable once known + const slots = resolveBracketTeams(match); + if (state.team && slots.home.team?.id !== state.team && slots.away.team?.id !== state.team) return false; + if (state.search) { + const haystack = normalize(`${slots.home.label} ${slots.away.label} ${match.city} ${match.stadium}`); + if (!haystack.includes(normalize(state.search))) return false; + } + } + return true; +} + +// -------------------------------------------------------------- render + +function renderList() { + const { matches } = getData(); + const direction = state.sort === 'asc' ? 1 : -1; + const favIds = state.favOnly + ? new Set(getFavoriteMatches(matches, getFavorites()).map((m) => m.id)) + : null; + const filtered = matches + .filter((m) => (!favIds || favIds.has(m.id)) && matchesFilters(m)) + .sort((a, b) => direction * (matchDateUTC(a) - matchDateUTC(b) || a.id - b.id)); + + byId('sched-count').textContent = + `${filtered.length} ${filtered.length === 1 ? t('schedule.match') : t('schedule.matches')}`; + byId('sched-list').innerHTML = filtered.length + ? filtered.map(matchCardHTML).join('') + : `

${t('schedule.noResults')}

`; +} + +function teamColumnHTML(slot) { + if (!slot.team) return `
${slot.label}
`; + const fav = getFavorites().includes(slot.team.id); + return ` +
+ + ${slot.team.name} + + +
`; +} + +function matchCardHTML(match) { + const { resultByMatchId, stadiumByName } = getData(); + const result = resultByMatchId.get(match.id); + const status = result?.status ?? 'scheduled'; + const stadium = stadiumByName.get(match.stadium); + + let statusChip = ''; + if (status === 'live') statusChip = `● ${t('hero.live')}`; + else if (status === 'finished') statusChip = `${t('status.finished')}`; + + let center; + if (status === 'finished' || status === 'live') { + const pens = result.penalties + ? `(${result.penalties.home}–${result.penalties.away} ${t('status.pens')})` + : ''; + center = `
${result.homeScore}${result.awayScore}${pens}
`; + } else { + center = `${t('hero.vs')}`; + } + + const slots = resolveBracketTeams(match); + const favorites = getFavorites(); + const hasFav = favorites.includes(slots.home.team?.id) || favorites.includes(slots.away.team?.id); + return ` +
+
+ ${translatePhase(match.phase)} + ${statusChip} +
+
+ ${teamColumnHTML(slots.home)} + ${center} + ${teamColumnHTML(slots.away)} +
+
${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}
+
`; +} diff --git a/assets/js/stadiums.js b/assets/js/stadiums.js new file mode 100644 index 0000000..eb7443f --- /dev/null +++ b/assets/js/stadiums.js @@ -0,0 +1,52 @@ +// stadiums.js — stadium cards: image, name, city, capacity, matches held. +// "View matches" jumps to the schedule pre-filtered by stadium. + +import { getData, navigateTo } from './app.js'; +import { t, getLocale } from './i18n.js'; +import { setStadiumFilter } from './schedule.js'; + +export function initStadiums() { + render(); + document.addEventListener('langchange', render); +} + +function render() { + const { stadiums, matches } = getData(); + const root = document.getElementById('stadiums-root'); + + const matchCounts = new Map(); + for (const match of matches) { + matchCounts.set(match.stadium, (matchCounts.get(match.stadium) ?? 0) + 1); + } + const numberFmt = new Intl.NumberFormat(getLocale()); + + root.innerHTML = ` +
+ ${stadiums.map((stadium) => stadiumCardHTML(stadium, matchCounts.get(stadium.name) ?? 0, numberFmt)).join('')} +
`; + + for (const btn of root.querySelectorAll('[data-stadium]')) { + btn.addEventListener('click', () => { + setStadiumFilter(btn.dataset.stadium); + navigateTo('matches'); + }); + } +} + +function stadiumCardHTML(stadium, matchCount, numberFmt) { + const matchWord = matchCount === 1 ? t('schedule.match') : t('schedule.matches'); + return ` +
+ ${stadium.name} +
+

${stadium.name}

+

${stadium.city}

+

+ ${t('stadiums.capacity')}: ${numberFmt.format(stadium.capacity)} + ${matchCount} ${matchWord} +

+ +
+
`; +} diff --git a/assets/js/storage.js b/assets/js/storage.js new file mode 100644 index 0000000..f855eea --- /dev/null +++ b/assets/js/storage.js @@ -0,0 +1,44 @@ +// storage.js — thin localStorage wrapper. All keys are prefixed wc2026_ and +// values are JSON-serialized. Never touch localStorage outside this module. + +const PREFIX = 'wc2026_'; + +export function get(key, fallback = null) { + try { + const raw = localStorage.getItem(PREFIX + key); + return raw === null ? fallback : JSON.parse(raw); + } catch { + return fallback; + } +} + +export function set(key, value) { + try { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + } catch { + // storage full or unavailable (private mode) — app keeps working without persistence + } +} + +export function getPrefs() { + return get('prefs', {}); +} + +export function setPref(name, value) { + const prefs = getPrefs(); + prefs[name] = value; + set('prefs', prefs); +} + +export function getFavorites() { + return get('favorites', []); +} + +export function toggleFavorite(teamId) { + const favorites = getFavorites(); + const index = favorites.indexOf(teamId); + if (index >= 0) favorites.splice(index, 1); + else favorites.push(teamId); + set('favorites', favorites); + return favorites; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..6b8ba4f --- /dev/null +++ b/index.html @@ -0,0 +1,83 @@ + + + + + + + World Cup 2026 Hub + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + +