Fan Tier 2: venue pages, attack zones, broadcasts, push prefs, squads + UI/UX polish

Features (all from already-stored data):
- Venue pages: /venues hub + /venue/:name with each stadium's schedule,
  altitude badge (>1000 m), local timezone. Linked from the match hero,
  nav More sheet, and footer.
- Attacking-zones card on the match Stats tab (left/center/right share per
  side, from the stored FotMob payload).
- US broadcast networks on the pre-match hero (SUMMARY_V 6).
- Notification preferences: follow multiple teams + an all-matches feed
  ('*'); push_subs keyed by (endpoint, team) with a safe one-time migration;
  device drops its browser subscription only when the last follow is removed.
- FIFA tournament squad card on team pages (26-man list, position groups).
- Player match logs now show DNP rows for unused-squad appearances.
- OG/share meta for /player and /venue.

Polish (adversarial audit: 25 confirmed findings, all fixed):
- Mobile horizontal-scroll eliminated on /teams and /venues (grid-item
  min-w-0 blowout); two-line venue fixture rows.
- Truncation actually fires now across team fixtures, road-to-final,
  recent-form, H2H meetings, venue rows (min-w-0 on flex children).
- Team hero no longer detaches the standing block on wrap; bell denied-state
  no longer overflows the h1.
- AllMatchesBell: 36px touch target, aria-label, own i18n key, even rhythm.
- Server hardening: cached player index (one cross-fixture scan, not per
  request), migration rollback guard, 400 (not 500) on malformed name URLs,
  all-zero zones render nothing, tidyName fixes shouted particles (DE→De).
- German polish: removed stray English ("anytime", "Build-Geocode"), clearer
  squad note.

Gates: 170 tests, build, typecheck:server — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:05:27 +02:00
parent 0fd13d4c24
commit a1fb6d48c0
26 changed files with 726 additions and 104 deletions
+45 -7
View File
@@ -192,6 +192,9 @@ export function MatchPreviewPage() {
const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled'
? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee })
: null;
const tvText = !finished && preview.broadcasts.length > 0
? fmt(t.match.tvLine, { channels: preview.broadcasts.join(' · ') })
: null;
const heroChips: { label: string; value: string; gold?: boolean }[] = [];
if (hasScore) {
@@ -215,7 +218,7 @@ export function MatchPreviewPage() {
// Tabs only appear when their data exists (e.g. no Timeline before kickoff).
const tabs: TabKey[] = ['summary'];
if (preview.events.length > 0) tabs.push('timeline');
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
if (preview.liveStats || (rich && ((rich.fullStats?.length ?? 0) > 0 || rich.zones != null || rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
tabs.push('lineups');
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
@@ -250,7 +253,12 @@ export function MatchPreviewPage() {
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}
<span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)}
<span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)}
<span className="text-line-strong"> · </span>{preview.venue}
<span className="text-line-strong"> · </span>
{f.venue ? (
<Link to="/venue/$name" params={{ name: f.venue }} className="whitespace-nowrap transition-colors hover:text-accent">{preview.venue}</Link>
) : (
<span className="whitespace-nowrap">{preview.venue}</span>
)}
</div>
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
@@ -278,7 +286,7 @@ export function MatchPreviewPage() {
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
</div>
)}
{(weatherText || infoText) && (
{(weatherText || infoText || tvText) && (
<div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted">
{weatherText && (
<div className="flex flex-wrap items-center justify-center gap-2">
@@ -287,6 +295,7 @@ export function MatchPreviewPage() {
</div>
)}
{infoText && <div>{infoText}</div>}
{tvText && <div>{tvText}</div>}
</div>
)}
</div>
@@ -511,6 +520,35 @@ export function MatchPreviewPage() {
</CardBody>
</Card>
)}
{rich?.zones && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.zonesTitle}</span></CardHeader>
<CardBody className="space-y-3">
{([
['home', homeName, rich.zones.home, ['bg-accent/40', 'bg-accent/70', 'bg-accent']],
['away', awayName, rich.zones.away, ['bg-info/40', 'bg-info/70', 'bg-info']],
] as const).map(([side, name, z, tones]) => {
const total = z.left + z.center + z.right || 1;
return (
<div key={side}>
<div className="mb-1 text-sm font-medium text-ink">{name}</div>
<div className="flex h-3 overflow-hidden rounded-full bg-elevated">
<div className={tones[0]} style={{ width: `${(z.left / total) * 100}%` }} />
<div className={tones[1]} style={{ width: `${(z.center / total) * 100}%` }} />
<div className={tones[2]} style={{ width: `${(z.right / total) * 100}%` }} />
</div>
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
<span>{t.match.zones.left} {z.left}%</span>
<span>{t.match.zones.center} {z.center}%</span>
<span>{t.match.zones.right} {z.right}%</span>
</div>
</div>
);
})}
<p className="text-xs text-faint">{t.match.zonesNote}</p>
</CardBody>
</Card>
)}
{rich && rich.momentum.length >= 10 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
@@ -601,7 +639,7 @@ export function MatchPreviewPage() {
) : (
<Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
<CardBody className="grid grid-cols-2 gap-6">
<CardBody className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6">
{preview.lineups ? (
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
) : (
@@ -615,8 +653,8 @@ export function MatchPreviewPage() {
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.home.team && teamFlag(f.home.team)}</span> <span className="truncate">{homeName}</span></span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2 text-sm font-medium text-ink"><span className="shrink-0">{f.away.team && teamFlag(f.away.team)}</span> <span className="truncate">{awayName}</span></span><FormChips form={preview.form.away} /></div>
</CardBody>
</Card>
)}
@@ -633,7 +671,7 @@ export function MatchPreviewPage() {
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
<ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
<li key={i} className="flex items-center justify-between gap-2 text-ink-soft"><span className="shrink-0 text-faint">{m.date.slice(0, 10)}</span><span className="min-w-0 truncate text-right">{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
))}
</ul>
</div>