Daily digest on the Live tab: yesterday's results + title-odds movers
A compact card up top: yesterday's scores at a glance and the teams whose championship odds moved most over the last day (from the model's recompute history), with up/down trends linking through to team pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Newspaper, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { dayKeyOf } from '@/lib/format';
|
||||
import { useT } from '@/lib/i18n';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
|
||||
/** The day in brief: yesterday's results + the model's biggest title-odds moves. */
|
||||
export function DailyDigest() {
|
||||
const t = useT();
|
||||
const snapshot = useTournamentStore((s) => s.snapshot);
|
||||
const model = useTournamentStore((s) => s.model);
|
||||
|
||||
const digest = useMemo(() => {
|
||||
if (!snapshot) return null;
|
||||
const now = new Date();
|
||||
const yesterdayKey = dayKeyOf(new Date(now.getTime() - 24 * 3600_000).toISOString());
|
||||
const yesterday = snapshot.fixtures
|
||||
.filter((f) => f.status === 'finished' && dayKeyOf(f.kickoff) === yesterdayKey)
|
||||
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
|
||||
|
||||
let movers: { team: string; delta: number }[] = [];
|
||||
const hist = model?.history ?? [];
|
||||
if (hist.length >= 2) {
|
||||
const latest = hist[hist.length - 1]!;
|
||||
const cutoff = new Date(latest.t).getTime() - 20 * 3600_000;
|
||||
const base = [...hist].reverse().find((h) => new Date(h.t).getTime() <= cutoff) ?? hist[0]!;
|
||||
if (base !== latest) {
|
||||
const before = new Map(base.top.map((x) => [x.team, x.champion]));
|
||||
movers = latest.top
|
||||
.map((x) => ({ team: x.team, delta: x.champion - (before.get(x.team) ?? 0) }))
|
||||
.filter((m) => Math.abs(m.delta) >= 0.005)
|
||||
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||
.slice(0, 3);
|
||||
}
|
||||
}
|
||||
if (yesterday.length === 0 && movers.length === 0) return null;
|
||||
return { yesterday, movers };
|
||||
}, [snapshot, model]);
|
||||
|
||||
if (!digest) return null;
|
||||
|
||||
return (
|
||||
<Card className="mb-7">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<Newspaper size={15} className="text-accent" />
|
||||
<span className="font-display font-bold text-ink">{t.live.digestTitle}</span>
|
||||
</CardHeader>
|
||||
<CardBody className="grid gap-4 sm:grid-cols-2">
|
||||
{digest.yesterday.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-faint">{t.live.digestYesterday}</div>
|
||||
<ul className="space-y-1">
|
||||
{digest.yesterday.map((f) => (
|
||||
<li key={f.num}>
|
||||
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-1.5 text-sm text-ink hover:text-accent">
|
||||
<span aria-hidden>{f.home.team && teamFlag(f.home.team)}</span>
|
||||
<span className="truncate">{f.home.label}</span>
|
||||
<span className="tnum font-semibold">{f.homeScore}–{f.awayScore}</span>
|
||||
<span className="truncate">{f.away.label}</span>
|
||||
<span aria-hidden>{f.away.team && teamFlag(f.away.team)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{digest.movers.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-faint">{t.live.digestMovers}</div>
|
||||
<ul className="space-y-1">
|
||||
{digest.movers.map((m) => (
|
||||
<li key={m.team}>
|
||||
<Link to="/team/$name" params={{ name: m.team }} className="flex items-center gap-1.5 text-sm text-ink hover:text-accent">
|
||||
<span aria-hidden>{teamFlag(m.team)}</span>
|
||||
<span className="truncate">{m.team}</span>
|
||||
<span className={`tnum ml-auto inline-flex items-center gap-1 font-semibold ${m.delta > 0 ? 'text-accent' : 'text-danger'}`}>
|
||||
{m.delta > 0 ? <TrendingUp size={13} /> : <TrendingDown size={13} />}
|
||||
{m.delta > 0 ? '+' : ''}{(m.delta * 100).toFixed(1)} pp
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { MatchCard } from '@/components/MatchCard';
|
||||
import { DailyDigest } from '@/components/DailyDigest';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||
import { isToday } from '@/lib/format';
|
||||
@@ -76,6 +77,8 @@ export function LivePage() {
|
||||
subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
|
||||
/>
|
||||
|
||||
<DailyDigest />
|
||||
|
||||
{favMatch && (
|
||||
<section className="mb-7">
|
||||
<h2 className="smallcaps mb-2.5">★ {t.live.myTeam}</h2>
|
||||
|
||||
@@ -68,6 +68,9 @@ export const de: Dict = {
|
||||
comingUp: "Demnächst",
|
||||
myTeam: "Mein Team",
|
||||
latestResults: "Letzte Ergebnisse",
|
||||
digestTitle: "Der Tag in Kürze",
|
||||
digestYesterday: "Gestern",
|
||||
digestMovers: "Bewegung bei den Titelchancen",
|
||||
empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.",
|
||||
},
|
||||
match: {
|
||||
|
||||
@@ -67,6 +67,9 @@ export const en = {
|
||||
comingUp: "Coming up",
|
||||
myTeam: "My team",
|
||||
latestResults: "Latest results",
|
||||
digestTitle: "The day in brief",
|
||||
digestYesterday: "Yesterday",
|
||||
digestMovers: "Title-odds movers",
|
||||
empty: "The schedule is ready. Matches will appear here once the tournament kicks off.",
|
||||
},
|
||||
match: {
|
||||
|
||||
Reference in New Issue
Block a user