Surrounding screens refresh: live hero, row-style match cards, form bars, unified bracket

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:37:06 +02:00
parent d1d294da5d
commit 4faedd60c4
8 changed files with 204 additions and 77 deletions
+27 -1
View File
@@ -4,8 +4,32 @@ import { teamFlag } from '@/lib/teams';
import { fmt, useT } from '@/lib/i18n'; import { fmt, useT } from '@/lib/i18n';
import type { StandingRow } from '@/lib/types'; 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 <span className="text-faint"></span>;
return (
<span className="inline-flex items-end gap-[3px]" role="img" aria-label={form.join(' ')}>
{form.map((r, i) => (
<span
key={i}
title={t.groups.formLetter[r]}
className={cn(
'inline-block w-1.5 rounded-[2px]',
r === 'W' && 'h-3.5 bg-accent',
r === 'D' && 'h-2.5 bg-gold',
r === 'L' && 'h-2.5 bg-loss',
)}
/>
))}
</span>
);
}
/** One group's standings. Top 2 qualify directly; 3rd may advance as a best-third. */ /** 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<string, FormLetter[]> }) {
const t = useT(); const t = useT();
return ( return (
<div className="overflow-hidden rounded-xl border border-line bg-panel"> <div className="overflow-hidden rounded-xl border border-line bg-panel">
@@ -20,6 +44,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[]
<th className="w-8 py-1.5 text-center font-semibold">{t.groups.colPlayed}</th> <th className="w-8 py-1.5 text-center font-semibold">{t.groups.colPlayed}</th>
<th className="w-8 py-1.5 text-center font-semibold">{t.groups.colGoalDiff}</th> <th className="w-8 py-1.5 text-center font-semibold">{t.groups.colGoalDiff}</th>
<th className="w-9 py-1.5 text-center font-semibold">{t.groups.colPoints}</th> <th className="w-9 py-1.5 text-center font-semibold">{t.groups.colPoints}</th>
{form && <th className="w-10 py-1.5 pr-3 text-center font-semibold">{t.groups.colForm}</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -51,6 +76,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[]
{r.gd > 0 ? `+${r.gd}` : r.gd} {r.gd > 0 ? `+${r.gd}` : r.gd}
</td> </td>
<td className="py-1.5 text-center font-bold tabular-nums text-ink">{r.points}</td> <td className="py-1.5 text-center font-bold tabular-nums text-ink">{r.points}</td>
{form && <td className="py-1.5 pr-3 text-center"><FormBars form={form[r.team] ?? []} /></td>}
</tr> </tr>
))} ))}
</tbody> </tbody>
+30 -15
View File
@@ -1,7 +1,7 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams'; 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 { fmt, useFormat, useT, type Dict } from '@/lib/i18n';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { TeamLabel } from './TeamLabel'; import { TeamLabel } from './TeamLabel';
@@ -45,9 +45,25 @@ function StatusPill({ f }: { f: Fixture }) {
return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>; return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>;
} }
/** One team row of a played match: flag · name · score, loser dimmed. */
function TeamRow({ slot, score, dim }: { slot: TeamSlot; score: number; dim: boolean }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
<span aria-hidden className="text-xl leading-none">{slot.team ? teamFlag(slot.team) : '·'}</span>
<span className={cn('truncate text-[15px] font-semibold', dim ? 'text-faint' : 'text-ink')}>{slot.team ?? slot.label}</span>
<span className={cn('tnum ml-auto shrink-0 text-base font-semibold', dim ? 'text-faint' : 'text-ink')}>{score}</span>
</div>
);
}
export function MatchCard({ f }: { f: Fixture }) { export function MatchCard({ f }: { f: Fixture }) {
const { t, kickoffTime } = useFormat(); const { t } = useFormat();
const hasScore = f.status === 'live' || f.status === 'finished'; 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 ( return (
<Link <Link
to="/match/$num" to="/match/$num"
@@ -57,23 +73,22 @@ export function MatchCard({ f }: { f: Fixture }) {
f.status === 'live' && 'border-live/40 ring-1 ring-live/30', f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
)} )}
> >
<div className="mb-2 flex items-center justify-between"> <div className="mb-2.5 flex items-center justify-between gap-2">
<span className="smallcaps">{tag(f, t)}</span> <span className="smallcaps">{tag(f, t)}</span>
<StatusPill f={f} /> <StatusPill f={f} />
</div> </div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3"> {hasScore ? (
<TeamLabel slot={f.home} align="right" /> <div className="flex flex-col gap-1.5">
<div className="min-w-[64px] text-center"> <TeamRow slot={f.home} score={hs} dim={dimHome} />
{hasScore ? ( <TeamRow slot={f.away} score={as} dim={dimAway} />
<div className="tnum text-2xl font-bold text-ink">
{f.homeScore ?? 0}<span className="px-1.5 text-faint"></span>{f.awayScore ?? 0}
</div>
) : (
<div className="tnum text-sm font-semibold text-muted">{kickoffTime(f.kickoff)}</div>
)}
</div> </div>
<TeamLabel slot={f.away} align="left" /> ) : (
</div> <div className="flex items-center justify-center gap-3">
<span className="flex min-w-0 flex-1 justify-end"><TeamLabel slot={f.home} align="right" /></span>
<span className="tnum shrink-0 text-xs text-faint">{t.common.vs}</span>
<span className="flex min-w-0 flex-1"><TeamLabel slot={f.away} align="left" /></span>
</div>
)}
{f.status === 'scheduled' && <ModelHint num={f.num} />} {f.status === 'scheduled' && <ModelHint num={f.num} />}
<div className="mt-1.5 truncate text-center text-xs text-faint">{f.venue}</div> <div className="mt-1.5 truncate text-center text-xs text-faint">{f.venue}</div>
</Link> </Link>
+43 -54
View File
@@ -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 ( return (
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}> <div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 items-center gap-1.5">
{slot.team ? ( {slot.team ? (
<> <>
<span aria-hidden className="text-sm leading-none">{teamFlag(slot.team)}</span> <span aria-hidden className="text-sm leading-none">{teamFlag(slot.team)}</span>
<span className="truncate text-ink">{slot.team}</span> <span className={cn('truncate', loser ? 'text-faint' : 'text-ink')}>{slot.team}</span>
</> </>
) : ( ) : (
<span className="truncate text-xs italic text-faint" title={slot.label}>{slot.label}</span> <span className="truncate text-xs italic text-faint" title={slot.label}>{slot.label}</span>
)} )}
</span> </span>
{score !== null && <span className="tnum shrink-0 text-ink">{score}</span>} {score !== null && <span className={cn('tnum shrink-0', loser ? 'text-faint' : 'text-ink')}>{score}</span>}
</div> </div>
); );
} }
@@ -113,9 +113,9 @@ function BracketMatch({ f, probs, wide = false }: { f: Fixture; probs?: MatchPro
wide ? 'w-full' : 'w-52', wide ? 'w-full' : 'w-52',
)} )}
> >
<Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} /> <Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} loser={decided && f.homeScore! < f.awayScore!} />
<div className="h-px bg-line" /> <div className="h-px bg-line" />
<Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} /> <Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} loser={decided && f.awayScore! < f.homeScore!} />
{showProbs && <AdvanceBar probs={probs} />} {showProbs && <AdvanceBar probs={probs} />}
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[11px] text-faint"> <div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[11px] text-faint">
{fmt(t.common.matchNumberShort, { num: f.num })} · {f.status === 'finished' ? t.common.fullTimeShort : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} {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] }; return { byStage, third: byStage.third[0] };
}, [snapshot]); }, [snapshot]);
const [mobileStage, setMobileStage] = useState<KnockoutStage>('r32'); const [round, setRound] = useState<KnockoutStage>('r32');
const [whatIf, setWhatIf] = useState(false); const [whatIf, setWhatIf] = useState(false);
const [picks, setPicks] = useState<Picks>({}); const [picks, setPicks] = useState<Picks>({});
const [ratings, setRatings] = useState<RatingsModel | null>(null); const [ratings, setRatings] = useState<RatingsModel | null>(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]); const fixtures = useMemo(() => snapshot?.fixtures ?? [], [snapshot]);
useEffect(() => { useEffect(() => {
@@ -248,59 +251,45 @@ export function BracketPage() {
</div> </div>
)} )}
{/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */} {/* One round at a time behind a segmented picker — no sideways scroll,
<div className="md:hidden"> no connector lines, same layout on every screen size. */}
<div className="mb-4 flex overflow-hidden rounded-lg border border-line text-xs font-bold"> <div className="mb-4 flex overflow-hidden rounded-lg border border-line text-xs font-bold">
{COLUMN_STAGES.map((stage) => ( {COLUMN_STAGES.map((stage) => (
<button <button
key={stage} key={stage}
type="button" type="button"
onClick={() => setMobileStage(stage)} onClick={() => setRound(stage)}
className={cn( className={cn(
'flex-1 py-2.5 uppercase tracking-wide transition-colors', 'flex-1 py-2.5 uppercase tracking-wide transition-colors',
stage === mobileStage ? 'bg-accent-glow text-accent' : 'text-muted hover:text-ink', stage === round ? 'bg-accent-glow text-accent' : 'text-muted hover:text-ink',
)} )}
> >
{t.team.stageShort[stage]} <span className="sm:hidden">{t.team.stageShort[stage]}</span>
</button> <span className="hidden sm:inline">{t.stage[stage]}</span>
))} </button>
</div> ))}
<h2 className="smallcaps mb-2">{t.stage[mobileStage]}</h2>
<div className="flex flex-col gap-3">
{byStage[mobileStage].length
? byStage[mobileStage].map((f) => renderMatch(f, true))
: <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />}
{mobileStage === 'final' && third && (
<>
<h2 className="smallcaps mt-3">{t.bracket.thirdPlacePlayoff}</h2>
{renderMatch(third, true)}
</>
)}
</div>
</div> </div>
{/* Desktop: all rounds side by side. */} {!whatIf && round === 'final' && projected && (
<div className="hidden md:block"> <div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-gold/40 bg-gold/10 px-4 py-3">
<div className="overflow-x-auto pb-4"> <span aria-hidden className="text-2xl">{teamFlag(projected.team)}</span>
<div className="flex gap-5"> <span className="font-display font-bold text-ink">{fmt(t.bracket.projected, { team: projected.team })}</span>
{COLUMN_STAGES.map((stage) => ( <span className="tnum text-sm text-muted">{(projected.champion * 100).toFixed(1)}%</span>
<div key={stage} className="flex shrink-0 flex-col gap-3">
<h2 className="smallcaps">{t.stage[stage]}</h2>
{byStage[stage].length
? byStage[stage].map((f) => renderMatch(f))
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
</div>
))}
</div>
</div> </div>
)}
{third && ( <h2 className="smallcaps mb-2">{t.stage[round]}</h2>
<div className="mt-2"> <div className="grid gap-3 sm:grid-cols-2">
<h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2> {byStage[round].length
{renderMatch(third)} ? byStage[round].map((f) => renderMatch(f, true))
</div> : <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />}
)}
</div> </div>
{round === 'final' && third && (
<div className="mt-5">
<h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2>
<div className="grid gap-3 sm:grid-cols-2">{renderMatch(third, true)}</div>
</div>
)}
</div> </div>
); );
} }
+18 -2
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { GroupTable } from '@/components/GroupTable'; import { GroupTable, type FormLetter } from '@/components/GroupTable';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { groupScenarios, type TeamScenario } from '@/lib/scenarios'; import { groupScenarios, type TeamScenario } from '@/lib/scenarios';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
@@ -96,6 +96,22 @@ export function GroupsPage() {
const anyScenarios = Object.keys(scenarioByGroup).length > 0; const anyScenarios = Object.keys(scenarioByGroup).length > 0;
// Per-team result sequence (oldest → newest) from finished group matches.
const formByTeam = useMemo(() => {
const out: Record<string, FormLetter[]> = {};
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 ( return (
<div> <div>
<PageHeader <PageHeader
@@ -111,7 +127,7 @@ export function GroupsPage() {
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => ( {groups.map((g) => (
<div key={g}> <div key={g}>
<GroupTable group={g} rows={tables[g]!} /> <GroupTable group={g} rows={tables[g]!} form={formByTeam} />
{scenarioByGroup[g] && <Scenarios scenarios={scenarioByGroup[g]} />} {scenarioByGroup[g] && <Scenarios scenarios={scenarioByGroup[g]} />}
</div> </div>
))} ))}
+66 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Radio } from 'lucide-react'; import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { MatchCard } from '@/components/MatchCard'; import { MatchCard } from '@/components/MatchCard';
@@ -7,11 +8,67 @@ import { useTournamentStore } from '@/stores/tournamentStore';
import { useFavoriteStore } from '@/stores/favoriteStore'; import { useFavoriteStore } from '@/stores/favoriteStore';
import { isToday } from '@/lib/format'; import { isToday } from '@/lib/format';
import { upcomingByDay, type DayGroup } from '@/lib/fixtures'; 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 { fmt, useFormat } from '@/lib/i18n';
import type { Fixture } from '@/lib/types'; import type { Fixture } from '@/lib/types';
const UPCOMING_DAYS = 3; 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 (
<section className="mb-7">
<div className="mb-2.5 flex items-center gap-2">
<span className="live-dot h-2 w-2 rounded-full bg-live" />
<span className="text-xs font-bold uppercase tracking-[0.1em] text-live">{t.live.liveNowHeading}</span>
</div>
<Link
to="/match/$num"
params={{ num: String(f.num) }}
className="block rounded-2xl border border-live/40 bg-gradient-to-br from-panel to-surface p-5 ring-1 ring-live/30 transition-colors hover:border-live/60"
>
<div className="mb-3.5 flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-[0.1em]">
<span className="truncate text-faint">
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {f.venue}
</span>
<span className="tnum shrink-0 text-live">{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}</span>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3.5">
<span className="flex min-w-0 items-center justify-end gap-2.5">
<span className="truncate text-base font-bold text-ink md:text-[17px]">{f.home.label}</span>
{f.home.team && <span aria-hidden className="text-3xl leading-none">{teamFlag(f.home.team)}</span>}
</span>
<span className="tnum text-3xl font-semibold tracking-[-0.03em] text-ink">
{f.homeScore ?? 0}<span className="px-2 text-line-strong"></span>{f.awayScore ?? 0}
</span>
<span className="flex min-w-0 items-center gap-2.5">
{f.away.team && <span aria-hidden className="text-3xl leading-none">{teamFlag(f.away.team)}</span>}
<span className="truncate text-base font-bold text-ink md:text-[17px]">{f.away.label}</span>
</span>
</div>
{probs && (
<div aria-hidden className="mt-4 flex h-[9px] gap-[2px] overflow-hidden rounded-full bg-elevated">
<span className="bg-accent" style={{ width: `${probs.home * 100}%` }} />
<span className="bg-faint" style={{ width: `${probs.draw * 100}%` }} />
<span className="bg-info" style={{ width: `${probs.away * 100}%` }} />
</div>
)}
</Link>
</section>
);
}
function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) { function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
if (!fixtures.length) return null; if (!fixtures.length) return null;
return ( return (
@@ -70,6 +127,11 @@ export function LivePage() {
const nothingNow = !groups.live.length && !groups.today.length; 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 ( return (
<div> <div>
<PageHeader <PageHeader
@@ -79,7 +141,9 @@ export function LivePage() {
<DailyDigest /> <DailyDigest />
{favMatch && ( {hero && <LiveHero f={hero} />}
{favMatch && favMatch.num !== hero?.num && (
<section className="mb-7"> <section className="mb-7">
<h2 className="smallcaps mb-2.5"> {t.live.myTeam}</h2> <h2 className="smallcaps mb-2.5"> {t.live.myTeam}</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -88,7 +152,7 @@ export function LivePage() {
</section> </section>
)} )}
<Section title={t.live.liveNowHeading} fixtures={groups.live} /> <Section title={t.live.liveNowHeading} fixtures={otherLive} />
<Section title={t.live.todayHeading} fixtures={groups.today} /> <Section title={t.live.todayHeading} fixtures={groups.today} />
{groups.upcoming.map((g, i) => ( {groups.upcoming.map((g, i) => (
<Section <Section
+6 -3
View File
@@ -48,16 +48,19 @@ function ChampionBoard({ odds }: { odds: { team: string; champion: number }[] })
<div className="space-y-2"> <div className="space-y-2">
{top.map((t, i) => ( {top.map((t, i) => (
<div key={t.team} className="flex items-center gap-3"> <div key={t.team} className="flex items-center gap-3">
<span className="w-4 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span> <span className={cn('w-4 text-right text-xs font-bold tabular-nums', i === 0 ? 'text-gold' : 'text-faint')}>{i + 1}</span>
<span aria-hidden className="text-lg leading-none">{teamFlag(t.team)}</span> <span aria-hidden className="text-lg leading-none">{teamFlag(t.team)}</span>
<span className="w-28 shrink-0 truncate text-sm font-medium text-ink">{t.team}</span> <span className="w-28 shrink-0 truncate text-sm font-medium text-ink">{t.team}</span>
<div className="h-3 flex-1 overflow-hidden rounded-full bg-elevated"> <div className="h-3 flex-1 overflow-hidden rounded-full bg-elevated">
<div <div
className="h-full rounded-full bg-gradient-to-r from-accent-deep to-accent" className={cn(
'h-full rounded-full bg-gradient-to-r',
i === 0 ? 'from-gold/70 to-gold' : 'from-accent-deep to-accent',
)}
style={{ width: `${(t.champion / max) * 100}%` }} style={{ width: `${(t.champion / max) * 100}%` }}
/> />
</div> </div>
<span className="tnum w-12 shrink-0 text-right text-sm font-bold text-ink"> <span className={cn('tnum w-12 shrink-0 text-right text-sm font-bold', i === 0 ? 'text-gold' : 'text-ink')}>
{(t.champion * 100).toFixed(1)}% {(t.champion * 100).toFixed(1)}%
</span> </span>
</div> </div>
+7
View File
@@ -210,6 +210,12 @@ export const de: Dict = {
colPlayed: "Sp", colPlayed: "Sp",
colGoalDiff: "TD", colGoalDiff: "TD",
colPoints: "Pkt", colPoints: "Pkt",
colForm: "Form",
formLetter: {
W: "Sieg",
D: "Unentschieden",
L: "Niederlage",
},
}, },
bracket: { bracket: {
title: "Turnierbaum", title: "Turnierbaum",
@@ -221,6 +227,7 @@ export const de: Dict = {
reset: "Auswahl zurücksetzen", reset: "Auswahl zurücksetzen",
champion: "Dein Weltmeister: {team}", champion: "Dein Weltmeister: {team}",
pathChance: "Modell-Chance für genau diesen Lauf: {pct}", pathChance: "Modell-Chance für genau diesen Lauf: {pct}",
projected: "Prognostizierter Weltmeister: {team}",
}, },
teams: { teams: {
title: "Teams", title: "Teams",
+7
View File
@@ -209,6 +209,12 @@ export const en = {
colPlayed: "P", colPlayed: "P",
colGoalDiff: "GD", colGoalDiff: "GD",
colPoints: "Pts", colPoints: "Pts",
colForm: "Form",
formLetter: {
W: "Win",
D: "Draw",
L: "Loss",
},
}, },
bracket: { bracket: {
title: "Bracket", title: "Bracket",
@@ -220,6 +226,7 @@ export const en = {
reset: "Reset picks", reset: "Reset picks",
champion: "Your champion: {team}", champion: "Your champion: {team}",
pathChance: "Model chance of exactly this run: {pct}", pathChance: "Model chance of exactly this run: {pct}",
projected: "Projected champion: {team}",
}, },
teams: { teams: {
title: "Teams", title: "Teams",