diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index 5dd72f2..e9c578d 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -77,12 +77,9 @@ function ContextPanel({ e, ctx, shots, momentum, commentary, report }: { }) { const t = useT(); 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 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; const chance = shot && shot.xg != null ? 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), }) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '') : 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 (
- {story.map((s, j) =>

{s}

)} + {story.map((s, j) =>

{s}

)} {chance &&

{chance}

} {verdict &&

{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}

} {vars.map((v, j) =>

{v}

)} {showCtx &&

{ctx}

} - {story.length > 0 &&

{t.match.reportAttribution}

}
); } diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index ddba025..50ba168 100644 --- a/src/lib/matchEvents.test.ts +++ b/src/lib/matchEvents.test.ts @@ -185,6 +185,14 @@ describe('reportSentences', () => { 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', () => { const out = reportSentences(goal, story); expect(out[0]).toContain('drove a low shot through the legs of Williams'); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts index ed11873..f50c97d 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -222,7 +222,7 @@ const KIND_WORDS: Record = { * box"). Sentences must mention the player; those matching the event kind's * 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 []; const last = norm(e.title).split(' ').pop() ?? ''; if (last.length < 3) return []; @@ -230,10 +230,16 @@ export function reportSentences(e: ParsedEvent, story: string, max = 2): string[ const scored = story .split(/(?<=[.!?])\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 []; - const hits = scored.filter((x) => x.hit).map((x) => x.s); - return (hits.length ? hits : [scored[0]!.s]).slice(0, max); + scored.sort((a, b) => b.hits - a.hits); + return scored.slice(0, max).map((x) => x.s); } /** VAR / video-review feed lines belonging to this event (±1 minute) —