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 { 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';
|
||||||
|
import { DailyDigest } from '@/components/DailyDigest';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
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';
|
||||||
@@ -76,6 +77,8 @@ export function LivePage() {
|
|||||||
subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
|
subtitle={fmt(t.live.subtitle, { season: snapshot.season })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DailyDigest />
|
||||||
|
|
||||||
{favMatch && (
|
{favMatch && (
|
||||||
<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>
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ export const de: Dict = {
|
|||||||
comingUp: "Demnächst",
|
comingUp: "Demnächst",
|
||||||
myTeam: "Mein Team",
|
myTeam: "Mein Team",
|
||||||
latestResults: "Letzte Ergebnisse",
|
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.",
|
empty: "Der Spielplan ist geladen. Sobald das Turnier läuft, tauchen die Spiele hier auf.",
|
||||||
},
|
},
|
||||||
match: {
|
match: {
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export const en = {
|
|||||||
comingUp: "Coming up",
|
comingUp: "Coming up",
|
||||||
myTeam: "My team",
|
myTeam: "My team",
|
||||||
latestResults: "Latest results",
|
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.",
|
empty: "The schedule is ready. Matches will appear here once the tournament kicks off.",
|
||||||
},
|
},
|
||||||
match: {
|
match: {
|
||||||
|
|||||||
Reference in New Issue
Block a user