@@ -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",