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:
2026-06-12 15:57:17 +02:00
parent f2203ed4a4
commit f0e0f7390d
19 changed files with 636 additions and 13 deletions
+84 -3
View File
@@ -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>
)}