From 4faedd60c433111a700dc5316f54fc1f3629c254 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 11:37:06 +0200 Subject: [PATCH] Surrounding screens refresh: live hero, row-style match cards, form bars, unified bracket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design handoff steps 4-5: - LivePage: headline live-match hero (gradient card, live ring, smallcaps stage/venue + minute, big mono score, 3-segment in-play win-prob bar); favorite team's live match takes the hero slot - MatchCard: played matches as stacked team rows with the beaten side dimmed; scheduled matches as a centered 'vs' pairing (model hint and venue stay) - GroupTable: Form column — tiny W/D/L bars (accent/gold/loss) computed from finished group matches - BracketPage: one round-selector layout on every screen size (cards in a 2-col grid, losers dimmed), gold 'Projected champion' banner on the Final view; what-if mode, advance bars, R32 and the third-place play-off all unchanged - Predict title race: leader bar, rank and percentage in gold Co-Authored-By: Claude Fable 5 --- src/components/GroupTable.tsx | 28 +++++- src/components/MatchCard.tsx | 45 ++++++--- src/features/bracket/BracketPage.tsx | 97 +++++++++----------- src/features/groups/GroupsPage.tsx | 20 +++- src/features/live/LivePage.tsx | 68 +++++++++++++- src/features/predictions/PredictionsPage.tsx | 9 +- src/lib/i18n/de.ts | 7 ++ src/lib/i18n/en.ts | 7 ++ 8 files changed, 204 insertions(+), 77 deletions(-) diff --git a/src/components/GroupTable.tsx b/src/components/GroupTable.tsx index cc8abb6..8b38c62 100644 --- a/src/components/GroupTable.tsx +++ b/src/components/GroupTable.tsx @@ -4,8 +4,32 @@ import { teamFlag } from '@/lib/teams'; import { fmt, useT } from '@/lib/i18n'; import type { StandingRow } from '@/lib/types'; +export type FormLetter = 'W' | 'D' | 'L'; + +/** Tiny result bars, oldest → newest: W accent · D gold · L loss. */ +function FormBars({ form }: { form: FormLetter[] }) { + const t = useT(); + if (!form.length) return ; + return ( + + {form.map((r, i) => ( + + ))} + + ); +} + /** One group's standings. Top 2 qualify directly; 3rd may advance as a best-third. */ -export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] }) { +export function GroupTable({ group, rows, form }: { group: string; rows: StandingRow[]; form?: Record }) { const t = useT(); return (
@@ -20,6 +44,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] {t.groups.colPlayed} {t.groups.colGoalDiff} {t.groups.colPoints} + {form && {t.groups.colForm}} @@ -51,6 +76,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] {r.gd > 0 ? `+${r.gd}` : r.gd} {r.points} + {form && } ))} diff --git a/src/components/MatchCard.tsx b/src/components/MatchCard.tsx index 7b269a5..d69d67e 100644 --- a/src/components/MatchCard.tsx +++ b/src/components/MatchCard.tsx @@ -1,7 +1,7 @@ import { Link } from '@tanstack/react-router'; import { cn } from '@/lib/cn'; import { teamFlag } from '@/lib/teams'; -import type { Fixture } from '@/lib/types'; +import type { Fixture, TeamSlot } from '@/lib/types'; import { fmt, useFormat, useT, type Dict } from '@/lib/i18n'; import { useTournamentStore } from '@/stores/tournamentStore'; import { TeamLabel } from './TeamLabel'; @@ -45,9 +45,25 @@ function StatusPill({ f }: { f: Fixture }) { return {relativeKickoff(f.kickoff)}; } +/** One team row of a played match: flag · name · score, loser dimmed. */ +function TeamRow({ slot, score, dim }: { slot: TeamSlot; score: number; dim: boolean }) { + return ( +
+ {slot.team ? teamFlag(slot.team) : '·'} + {slot.team ?? slot.label} + {score} +
+ ); +} + export function MatchCard({ f }: { f: Fixture }) { - const { t, kickoffTime } = useFormat(); + const { t } = useFormat(); const hasScore = f.status === 'live' || f.status === 'finished'; + const hs = f.homeScore ?? 0; + const as = f.awayScore ?? 0; + // Dim the beaten side once the result stands; everyone stays lit while live. + const dimHome = f.status === 'finished' && hs < as; + const dimAway = f.status === 'finished' && as < hs; return ( -
+
{tag(f, t)}
-
- -
- {hasScore ? ( -
- {f.homeScore ?? 0}{f.awayScore ?? 0} -
- ) : ( -
{kickoffTime(f.kickoff)}
- )} + {hasScore ? ( +
+ +
- -
+ ) : ( +
+ + {t.common.vs} + +
+ )} {f.status === 'scheduled' && }
{f.venue}
diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index e4989fa..46a0566 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -66,20 +66,20 @@ function WhatIfMatch({ f, eff, pick, advHome, onPick, wide = false }: { ); } -function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) { +function Side({ slot, score, winner, loser }: { slot: TeamSlot; score: number | null; winner: boolean; loser: boolean }) { return (
{slot.team ? ( <> {teamFlag(slot.team)} - {slot.team} + {slot.team} ) : ( {slot.label} )} - {score !== null && {score}} + {score !== null && {score}}
); } @@ -113,9 +113,9 @@ function BracketMatch({ f, probs, wide = false }: { f: Fixture; probs?: MatchPro wide ? 'w-full' : 'w-52', )} > - f.awayScore!} /> + f.awayScore!} loser={decided && f.homeScore! < f.awayScore!} />
- f.homeScore!} /> + f.homeScore!} loser={decided && f.awayScore! < f.homeScore!} /> {showProbs && }
{fmt(t.common.matchNumberShort, { num: f.num })} · {f.status === 'finished' ? t.common.fullTimeShort : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} @@ -143,11 +143,14 @@ export function BracketPage() { return { byStage, third: byStage.third[0] }; }, [snapshot]); - const [mobileStage, setMobileStage] = useState('r32'); + const [round, setRound] = useState('r32'); const [whatIf, setWhatIf] = useState(false); const [picks, setPicks] = useState({}); const [ratings, setRatings] = useState(null); + // The Monte-Carlo title favorite — shown as the projected champion on the Final view. + const projected = model?.odds[0] ?? null; + const fixtures = useMemo(() => snapshot?.fixtures ?? [], [snapshot]); useEffect(() => { @@ -248,59 +251,45 @@ export function BracketPage() {
)} - {/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */} -
-
- {COLUMN_STAGES.map((stage) => ( - - ))} -
-

{t.stage[mobileStage]}

-
- {byStage[mobileStage].length - ? byStage[mobileStage].map((f) => renderMatch(f, true)) - :
} - {mobileStage === 'final' && third && ( - <> -

{t.bracket.thirdPlacePlayoff}

- {renderMatch(third, true)} - - )} -
+ {/* One round at a time behind a segmented picker — no sideways scroll, + no connector lines, same layout on every screen size. */} +
+ {COLUMN_STAGES.map((stage) => ( + + ))}
- {/* Desktop: all rounds side by side. */} -
-
-
- {COLUMN_STAGES.map((stage) => ( -
-

{t.stage[stage]}

- {byStage[stage].length - ? byStage[stage].map((f) => renderMatch(f)) - :
} -
- ))} -
+ {!whatIf && round === 'final' && projected && ( +
+ {teamFlag(projected.team)} + {fmt(t.bracket.projected, { team: projected.team })} + {(projected.champion * 100).toFixed(1)}%
+ )} - {third && ( -
-

{t.bracket.thirdPlacePlayoff}

- {renderMatch(third)} -
- )} +

{t.stage[round]}

+
+ {byStage[round].length + ? byStage[round].map((f) => renderMatch(f, true)) + :
}
+ {round === 'final' && third && ( +
+

{t.bracket.thirdPlacePlayoff}

+
{renderMatch(third, true)}
+
+ )}
); } diff --git a/src/features/groups/GroupsPage.tsx b/src/features/groups/GroupsPage.tsx index 96b2ec8..f046c14 100644 --- a/src/features/groups/GroupsPage.tsx +++ b/src/features/groups/GroupsPage.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { Link } from '@tanstack/react-router'; import { Users } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; -import { GroupTable } from '@/components/GroupTable'; +import { GroupTable, type FormLetter } from '@/components/GroupTable'; import { useTournamentStore } from '@/stores/tournamentStore'; import { groupScenarios, type TeamScenario } from '@/lib/scenarios'; import { teamFlag } from '@/lib/teams'; @@ -96,6 +96,22 @@ export function GroupsPage() { const anyScenarios = Object.keys(scenarioByGroup).length > 0; + // Per-team result sequence (oldest → newest) from finished group matches. + const formByTeam = useMemo(() => { + const out: Record = {}; + const played = (snapshot?.fixtures ?? []) + .filter((f) => f.stage === 'group' && f.status === 'finished' && f.homeScore != null && f.awayScore != null) + .sort((a, b) => a.kickoff.localeCompare(b.kickoff)); + for (const f of played) { + const h = f.home.team, a = f.away.team; + if (!h || !a) continue; + const hs = f.homeScore!, as = f.awayScore!; + (out[h] ??= []).push(hs > as ? 'W' : hs < as ? 'L' : 'D'); + (out[a] ??= []).push(as > hs ? 'W' : as < hs ? 'L' : 'D'); + } + return out; + }, [snapshot]); + return (
{groups.map((g) => (
- + {scenarioByGroup[g] && }
))} diff --git a/src/features/live/LivePage.tsx b/src/features/live/LivePage.tsx index a4af72e..4164984 100644 --- a/src/features/live/LivePage.tsx +++ b/src/features/live/LivePage.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { Link } from '@tanstack/react-router'; import { Radio } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { MatchCard } from '@/components/MatchCard'; @@ -7,11 +8,67 @@ import { useTournamentStore } from '@/stores/tournamentStore'; import { useFavoriteStore } from '@/stores/favoriteStore'; import { isToday } from '@/lib/format'; import { upcomingByDay, type DayGroup } from '@/lib/fixtures'; +import { inPlayProbs } from '@/lib/model/inplay'; +import { teamFlag } from '@/lib/teams'; import { fmt, useFormat } from '@/lib/i18n'; import type { Fixture } from '@/lib/types'; const UPCOMING_DAYS = 3; +/** The headline live match: big scoreboard card with a live win-probability + * bar (in-play when the score is known, else the pre-match model). */ +function LiveHero({ f }: { f: Fixture }) { + const { t } = useFormat(); + const pred = useTournamentStore((s) => s.model?.matches.find((m) => m.num === f.num)); + const probs = useMemo(() => { + if (!pred) return null; + if (f.homeScore != null && f.awayScore != null) { + return inPlayProbs(pred.lambdaHome, pred.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore); + } + return pred.probs; + }, [pred, f]); + return ( +
+
+ + {t.live.liveNowHeading} +
+ +
+ + {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {f.venue} + + {f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow} +
+
+ + {f.home.label} + {f.home.team && {teamFlag(f.home.team)}} + + + {f.homeScore ?? 0}{f.awayScore ?? 0} + + + {f.away.team && {teamFlag(f.away.team)}} + {f.away.label} + +
+ {probs && ( +
+ + + +
+ )} + +
+ ); +} + function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) { if (!fixtures.length) return null; return ( @@ -70,6 +127,11 @@ export function LivePage() { const nothingNow = !groups.live.length && !groups.today.length; + // The headline live card: the starred team's match when it's playing, + // otherwise the earliest-kicked-off live match. The rest stay in the grid. + const hero = (favorite && groups.live.find((m) => m.home.team === favorite || m.away.team === favorite)) || groups.live[0] || null; + const otherLive = hero ? groups.live.filter((m) => m.num !== hero.num) : groups.live; + return (
- {favMatch && ( + {hero && } + + {favMatch && favMatch.num !== hero?.num && (

★ {t.live.myTeam}

@@ -88,7 +152,7 @@ export function LivePage() {
)} -
+
{groups.upcoming.map((g, i) => (
{top.map((t, i) => (
- {i + 1} + {i + 1} {teamFlag(t.team)} {t.team}
- + {(t.champion * 100).toFixed(1)}%
diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 1d73341..7827721 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -210,6 +210,12 @@ export const de: Dict = { colPlayed: "Sp", colGoalDiff: "TD", colPoints: "Pkt", + colForm: "Form", + formLetter: { + W: "Sieg", + D: "Unentschieden", + L: "Niederlage", + }, }, bracket: { title: "Turnierbaum", @@ -221,6 +227,7 @@ export const de: Dict = { reset: "Auswahl zurücksetzen", champion: "Dein Weltmeister: {team}", pathChance: "Modell-Chance für genau diesen Lauf: {pct}", + projected: "Prognostizierter Weltmeister: {team}", }, teams: { title: "Teams", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index cfd1f85..8d06e58 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -209,6 +209,12 @@ export const en = { colPlayed: "P", colGoalDiff: "GD", colPoints: "Pts", + colForm: "Form", + formLetter: { + W: "Win", + D: "Draw", + L: "Loss", + }, }, bracket: { title: "Bracket", @@ -220,6 +226,7 @@ export const en = { reset: "Reset picks", champion: "Your champion: {team}", pathChance: "Model chance of exactly this run: {pct}", + projected: "Projected champion: {team}", }, teams: { title: "Teams",