Tier 2: shootout panel, road to the final, ESPN videos, ICS export, injuries plumbing
- Penalty shootout panel: shootout-flagged events get a kicker-by-kicker dot strip on the Summary tab (and stay OFF the timeline/running score). Parsing is defensive — validate against the first real shootout June 28. - Road to the final on team profiles: the model's projected knockout run (teamPath in whatif.ts forces the team through the model bracket) with per-round reach probabilities and a title-chance chip. Teams the model doesn't project through still get a hypothetical runner-up road. - Match videos: ESPN summaries carry per-match clips (SUMMARY_V=5 keeps headline/duration/thumbnail/link) — a Videos card on the Summary tab, clips open on ESPN. Tokenless; replaces the parked ScoreBat idea. - ICS calendar export: /api/calendar.ics[?team=X] (RFC 5545, escaped, folded, CRLF) + an Add-to-calendar chip on team pages. - Injuries plumbing (dormant until API_FOOTBALL_KEY is set): twice-daily API-Football sweep into the injuries table, surfaced on team profiles and the match availability banner next to suspensions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRight, Ban, Shield, Shirt } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Ban, Clapperboard, Goal, Shield, Shirt, Stethoscope } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
@@ -14,7 +14,7 @@ import { EventIcon, MatchTimeline } from '@/components/MatchTimeline';
|
||||
import { LineupPitch } from '@/components/LineupPitch';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { goalEvents } from '@/lib/matchEvents';
|
||||
import { goalEvents, shootoutKicks } from '@/lib/matchEvents';
|
||||
import { redCards } from '@/lib/discipline';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
@@ -162,6 +162,7 @@ export function MatchPreviewPage() {
|
||||
], [t]);
|
||||
|
||||
const goals = useMemo(() => (preview ? goalEvents(preview.events) : []), [preview]);
|
||||
const kicks = useMemo(() => (preview ? shootoutKicks(preview.events) : []), [preview]);
|
||||
|
||||
if (failed) return <div className="py-16 text-center text-muted">{t.match.loadError} <Link to="/" className="text-accent">{t.match.backToLive}</Link></div>;
|
||||
if (!preview || !f) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
|
||||
@@ -347,6 +348,40 @@ export function MatchPreviewPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{kicks.length > 0 && (
|
||||
<Card className="border-gold/40">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Goal size={16} className="text-gold" />
|
||||
<span className="font-display font-bold text-ink">{t.match.shootoutTitle}</span>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-2">
|
||||
{(['home', 'away'] as const).map((side) => {
|
||||
const sideKicks = kicks.filter((k) => k.side === side);
|
||||
if (!sideKicks.length) return null;
|
||||
const team = side === 'home' ? f.home.team : f.away.team;
|
||||
return (
|
||||
<div key={side} className="flex items-center gap-2.5 text-sm">
|
||||
<span className="flex w-32 shrink-0 items-center gap-1.5 font-medium text-ink">
|
||||
{team && <span aria-hidden>{teamFlag(team)}</span>}
|
||||
<span className="truncate">{side === 'home' ? homeName : awayName}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{sideKicks.map((k, i) => (
|
||||
<span
|
||||
key={i}
|
||||
title={k.player ?? ''}
|
||||
className={`h-3 w-3 rounded-full ${k.scored ? 'bg-accent' : 'border-2 border-loss'}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="tnum ml-auto shrink-0 font-bold text-ink">{sideKicks.filter((k) => k.scored).length}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-faint">{t.match.shootoutNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{/* live matches keep the pre-match view alongside the in-play one */}
|
||||
{inPlay && preview.prediction && modelCard}
|
||||
{finished && timeline.length >= 2 && (
|
||||
@@ -365,6 +400,38 @@ export function MatchPreviewPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.videos.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Clapperboard size={16} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.match.videos}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{preview.videos.map((v) => (
|
||||
<a
|
||||
key={v.href}
|
||||
href={v.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex gap-3 rounded-lg border border-line p-2 transition-colors hover:border-line-strong hover:bg-panel-2"
|
||||
>
|
||||
{v.thumbnail && <img src={v.thumbnail} alt="" loading="lazy" className="h-14 w-24 shrink-0 rounded object-cover" />}
|
||||
<span className="min-w-0">
|
||||
<span className="line-clamp-2 text-sm font-medium text-ink group-hover:text-accent">{v.headline}</span>
|
||||
{v.duration != null && (
|
||||
<span className="mt-0.5 block text-[11px] text-faint">
|
||||
{Math.floor(v.duration / 60)}:{String(v.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-faint">{t.match.videosNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -430,7 +497,8 @@ export function MatchPreviewPage() {
|
||||
{/* ── Lineups ── */}
|
||||
{tab === 'lineups' && (
|
||||
<div className="space-y-4">
|
||||
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0) && (
|
||||
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0 ||
|
||||
preview.injuries.home.length > 0 || preview.injuries.away.length > 0) && (
|
||||
<Card className="border-loss/30">
|
||||
<CardBody className="space-y-1.5 text-sm">
|
||||
{([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const)
|
||||
@@ -442,6 +510,19 @@ export function MatchPreviewPage() {
|
||||
<span>{fmt(t.match.suspended, { players: players.join(', ') })}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['home', 'away'] as const)
|
||||
.filter((side) => preview.injuries[side].length > 0)
|
||||
.map((side) => (
|
||||
<div key={`inj-${side}`} className="flex items-center gap-2 text-ink-soft">
|
||||
<Stethoscope size={14} className="shrink-0 text-gold" />
|
||||
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
|
||||
<span>
|
||||
{fmt(t.match.unavailable, {
|
||||
players: preview.injuries[side].map((i) => (i.reason ? `${i.player} (${i.reason})` : i.player)).join(', '),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user