Cleaner event panels: hide raw feed prose, one best report sentence
- the ESPN feed description and attribution line are gone from goal
panels whenever the report narrative or shot data tells the story
(attribution lives in a tooltip); bookings keep their reason line
- one report sentence per event, ranked by event vocabulary with a
bonus when the player is the actor ('Quinones, who drove a low
shot…' beats 'goals from Quinones and Jimenez gave…')
- momentum verdicts only ever applied to goals (a clear-pressure
booking no longer claims it was 'scored against the run of play')
Routine events the report never mentions (e.g. an early booking) show
what the sources have: the reason. There is no richer feed for those.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,12 +77,9 @@ function ContextPanel({ e, ctx, shots, momentum, commentary, report }: {
|
|||||||
}) {
|
}) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const shot = goalShot(e, shots);
|
const shot = goalShot(e, shots);
|
||||||
const verdict = momentumVerdict(e, momentum);
|
const verdict = e.kind === 'goal' ? momentumVerdict(e, momentum) : null;
|
||||||
const vars = varLines(e, commentary);
|
const vars = varLines(e, commentary);
|
||||||
const story = reportSentences(e, report);
|
const story = reportSentences(e, report);
|
||||||
// A booking's ctx falls back to the raw "is shown the red card" line when
|
|
||||||
// the feed gives no reason — pure noise once the report explains it.
|
|
||||||
const showCtx = !(story.length > 0 && (e.kind === 'yellow' || e.kind === 'red') && ctx === e.raw.trim());
|
|
||||||
const situations = t.match.situations as Record<string, string>;
|
const situations = t.match.situations as Record<string, string>;
|
||||||
const chance = shot && shot.xg != null
|
const chance = shot && shot.xg != null
|
||||||
? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, {
|
? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, {
|
||||||
@@ -92,14 +89,19 @@ function ContextPanel({ e, ctx, shots, momentum, commentary, report }: {
|
|||||||
pct: Math.round(shot.xg * 100),
|
pct: Math.round(shot.xg * 100),
|
||||||
}) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '')
|
}) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '')
|
||||||
: null;
|
: null;
|
||||||
|
// The raw feed line is a fallback, not a feature: a goal's prose hides once
|
||||||
|
// the report or shot data tells the story; a booking's no-reason sentence
|
||||||
|
// hides once the report explains it. Real reasons ("For a bad foul.") stay.
|
||||||
|
const showCtx = e.kind === 'goal'
|
||||||
|
? story.length === 0 && !chance
|
||||||
|
: !(story.length > 0 && ctx === e.raw.trim());
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
|
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
|
||||||
{story.map((s, j) => <p key={j} className="text-ink-soft">{s}</p>)}
|
{story.map((s, j) => <p key={j} className="text-ink-soft" title={t.match.reportAttribution}>{s}</p>)}
|
||||||
{chance && <p className="font-medium text-muted">{chance}</p>}
|
{chance && <p className="font-medium text-muted">{chance}</p>}
|
||||||
{verdict && <p className="text-muted">{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}</p>}
|
{verdict && <p className="text-muted">{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}</p>}
|
||||||
{vars.map((v, j) => <p key={j} className="text-gold">{v}</p>)}
|
{vars.map((v, j) => <p key={j} className="text-gold">{v}</p>)}
|
||||||
{showCtx && <p className="text-faint">{ctx}</p>}
|
{showCtx && <p className="text-faint">{ctx}</p>}
|
||||||
{story.length > 0 && <p className="text-[10px] uppercase tracking-wide text-faint">{t.match.reportAttribution}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ describe('reportSentences', () => {
|
|||||||
expect(out[0]).toContain('red-carded for bringing down Gutierrez');
|
expect(out[0]).toContain('red-carded for bringing down Gutierrez');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers the most event-specific sentence over a generic summary', () => {
|
||||||
|
const noisy = "Goals from Quinones and Jimenez gave Mexico the win. " +
|
||||||
|
"They were a goal up when the ball fell to Quinones, who drove a low shot through the legs of Williams to score in the ninth minute.";
|
||||||
|
const out = reportSentences(goal, noisy);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]).toContain('drove a low shot');
|
||||||
|
});
|
||||||
|
|
||||||
it('finds the goal narrative despite missing diacritics in the report', () => {
|
it('finds the goal narrative despite missing diacritics in the report', () => {
|
||||||
const out = reportSentences(goal, story);
|
const out = reportSentences(goal, story);
|
||||||
expect(out[0]).toContain('drove a low shot through the legs of Williams');
|
expect(out[0]).toContain('drove a low shot through the legs of Williams');
|
||||||
|
|||||||
+10
-4
@@ -222,7 +222,7 @@ const KIND_WORDS: Record<string, RegExp> = {
|
|||||||
* box"). Sentences must mention the player; those matching the event kind's
|
* box"). Sentences must mention the player; those matching the event kind's
|
||||||
* vocabulary win, so a player's card sentence beats his goal sentence.
|
* vocabulary win, so a player's card sentence beats his goal sentence.
|
||||||
*/
|
*/
|
||||||
export function reportSentences(e: ParsedEvent, story: string, max = 2): string[] {
|
export function reportSentences(e: ParsedEvent, story: string, max = 1): string[] {
|
||||||
if (!story || e.kind === 'sub' || e.kind === 'other') return [];
|
if (!story || e.kind === 'sub' || e.kind === 'other') return [];
|
||||||
const last = norm(e.title).split(' ').pop() ?? '';
|
const last = norm(e.title).split(' ').pop() ?? '';
|
||||||
if (last.length < 3) return [];
|
if (last.length < 3) return [];
|
||||||
@@ -230,10 +230,16 @@ export function reportSentences(e: ParsedEvent, story: string, max = 2): string[
|
|||||||
const scored = story
|
const scored = story
|
||||||
.split(/(?<=[.!?])\s+/)
|
.split(/(?<=[.!?])\s+/)
|
||||||
.filter((s) => s.length > 20 && s.length < 400 && new RegExp(`\\b${last}\\b`, 'i').test(norm(s)))
|
.filter((s) => s.length > 20 && s.length < 400 && new RegExp(`\\b${last}\\b`, 'i').test(norm(s)))
|
||||||
.map((s) => ({ s: s.trim(), hit: kindRe ? kindRe.test(s) : false }));
|
.map((s) => {
|
||||||
|
let hits = kindRe ? (s.match(new RegExp(kindRe.source, 'gi')) ?? []).length : 0;
|
||||||
|
// The player ACTING ("Quinones, who drove a low shot") outranks the
|
||||||
|
// player merely listed ("goals from Quinones and Jimenez gave…").
|
||||||
|
if (kindRe && new RegExp(`\\b${last}\\b[^.]{0,60}?${kindRe.source}`, 'i').test(norm(s))) hits += 2;
|
||||||
|
return { s: s.trim(), hits };
|
||||||
|
});
|
||||||
if (!scored.length) return [];
|
if (!scored.length) return [];
|
||||||
const hits = scored.filter((x) => x.hit).map((x) => x.s);
|
scored.sort((a, b) => b.hits - a.hits);
|
||||||
return (hits.length ? hits : [scored[0]!.s]).slice(0, max);
|
return scored.slice(0, max).map((x) => x.s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** VAR / video-review feed lines belonging to this event (±1 minute) —
|
/** VAR / video-review feed lines belonging to this event (±1 minute) —
|
||||||
|
|||||||
Reference in New Issue
Block a user