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:
2026-06-12 01:14:56 +02:00
parent a91f7902e4
commit b61d839c8e
4 changed files with 101 additions and 0 deletions
+92
View File
@@ -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>
);
}
+3
View File
@@ -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>
+3
View File
@@ -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: {
+3
View File
@@ -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: {