From e5fe900fe899c9eb43e0dd3cb03de54776615bde Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Tue, 27 Jan 2026 15:47:52 +0000 Subject: [PATCH 1/8] add header to football match info page --- .../src/components/FootballMatchInfoPage.tsx | 14 ++ dotcom-rendering/src/footballMatch.ts | 4 +- dotcom-rendering/src/footballMatchStats.ts | 4 +- dotcom-rendering/src/footballMatchV2.ts | 209 +++++++++++++++++- .../src/frontend/feFootballMatchPage.ts | 6 +- .../src/layouts/SportDataPageLayout.tsx | 26 ++- dotcom-rendering/src/lib/parse.ts | 37 ++++ .../src/server/handler.sportDataPage.web.ts | 10 + dotcom-rendering/src/sportDataPage.ts | 2 + 9 files changed, 296 insertions(+), 16 deletions(-) diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx index 7ebcebbc733..198e3b36b4a 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx @@ -1,16 +1,20 @@ import { css } from '@emotion/react'; import { from } from '@guardian/source/foundations'; import { type FootballMatchStats } from '../footballMatchStats'; +import { type FootballMatch } from '../footballMatchV2'; import { type FootballTableSummary } from '../footballTables'; import { grid } from '../grid'; import { palette } from '../palette'; +import { FootballMatchHeader } from './FootballMatchHeader/FootballMatchHeader'; import { FootballMatchInfo } from './FootballMatchInfo'; export const FootballMatchInfoPage = ({ matchStats, + matchInfo, table, }: { matchStats: FootballMatchStats; + matchInfo: FootballMatch; table?: FootballTableSummary; }) => { return ( @@ -20,6 +24,16 @@ export const FootballMatchInfoPage = ({ ${grid.column.centre}; `} > + diff --git a/dotcom-rendering/src/footballMatch.ts b/dotcom-rendering/src/footballMatch.ts index c9c97292bab..9fa25fca96a 100644 --- a/dotcom-rendering/src/footballMatch.ts +++ b/dotcom-rendering/src/footballMatch.ts @@ -1,7 +1,7 @@ import { isOneOf } from '@guardian/libs'; import { listParse, replaceLiveMatchStatus } from './footballMatches'; import type { - FEFootballMatch, + FEFootballMatchStats, FEFootballPlayer, FEFootballPlayerEvent, FEFootballTeam, @@ -113,7 +113,7 @@ const parseTeam = ( })); export const parse = ( - feFootballMatch: FEFootballMatch, + feFootballMatch: FEFootballMatchStats, ): Result => parseTeam(feFootballMatch.homeTeam).flatMap((homeTeam) => parseTeam(feFootballMatch.awayTeam).map((awayTeam) => ({ diff --git a/dotcom-rendering/src/footballMatchStats.ts b/dotcom-rendering/src/footballMatchStats.ts index 060cfd6e910..2442f9c76eb 100644 --- a/dotcom-rendering/src/footballMatchStats.ts +++ b/dotcom-rendering/src/footballMatchStats.ts @@ -2,7 +2,7 @@ import { isOneOf } from '@guardian/libs'; import { listParse } from './footballMatches'; import type { FootballTeam } from './footballTeam'; import type { - FEFootballMatch, + FEFootballMatchStats, FEFootballPlayer, FEFootballPlayerEvent, FEFootballTeam, @@ -141,7 +141,7 @@ const parseTeamWithStats = ( })); export const parseMatchStats = ( - feFootballMatch: FEFootballMatch, + feFootballMatch: FEFootballMatchStats, ): Result => parseTeamWithStats(feFootballMatch.homeTeam).flatMap((homeTeam) => parseTeamWithStats(feFootballMatch.awayTeam).map((awayTeam) => ({ diff --git a/dotcom-rendering/src/footballMatchV2.ts b/dotcom-rendering/src/footballMatchV2.ts index b8865d47150..437d32e210e 100644 --- a/dotcom-rendering/src/footballMatchV2.ts +++ b/dotcom-rendering/src/footballMatchV2.ts @@ -1,4 +1,15 @@ +import { isUndefined } from '@guardian/libs'; +import { replaceLiveMatchStatus } from './footballMatches'; import type { FootballTeam } from './footballTeam'; +import type { + FEFixture, + FEFootballMatch, + FEMatchDay, + FEResult, +} from './frontend/feFootballMatchListPage'; +import { oneOf, parseDate } from './lib/parse'; +import { error, type Result } from './lib/result'; +import { cleanTeamName } from './sportDataPage'; /** * There are three states a football match can be in. @@ -50,7 +61,7 @@ export type MatchResult = MatchData & { type MatchData = { paId: string; kickOff: Date; - venue: string; + venue?: string; }; /** @@ -61,3 +72,199 @@ type FootballMatchTeamWithScore = FootballTeam & { score: number; scorers: string[]; }; + +type FootballDayInvalidDate = { + kind: 'FootballDayInvalidDate'; + message: string; +}; + +type ExpectedMatchDayFixture = { + kind: 'ExpectedMatchDayFixture'; + message: string; +}; + +type FootballMatchInvalidDate = { + kind: 'FootballMatchInvalidDate'; + message: string; +}; + +type ExpectedMatchDayResult = { + kind: 'ExpectedMatchDayResult'; + message: string; +}; + +type ExpectedMatchDayLive = { + kind: 'ExpectedMatchDayLive'; + message: string; +}; + +type InvalidMatchDay = { + kind: 'InvalidMatchDay'; + errors: Array; +}; + +type UnexpectedLiveMatch = { + kind: 'UnexpectedLiveMatch'; + message: string; +}; + +type ParserError = + | FootballDayInvalidDate + | ExpectedMatchDayFixture + | FootballMatchInvalidDate + | ExpectedMatchDayResult + | ExpectedMatchDayLive + | InvalidMatchDay + | UnexpectedLiveMatch; + +const parseMatchDate = (date: string): Result => { + // Frontend appends a timezone in square brackets + const isoDate = date.split('[')[0]; + + if (isUndefined(isoDate)) { + return error( + `Expected a match date with a timezone appended in square brackets at the end, but instead got ${date}`, + ); + } + + return parseDate(isoDate); +}; + +const parseFixture = ( + feFixture: FEFixture | FEMatchDay, +): Result => { + if ( + feFixture.type === 'MatchDay' && + (feFixture.result || feFixture.liveMatch) + ) { + return error({ + kind: 'ExpectedMatchDayFixture', + message: `A fixture match day must not have 'liveMatch' or 'result' set to 'true', but this was not the case for ${feFixture.id}`, + }); + } + + return parseMatchDate(feFixture.date) + .mapError((message) => ({ + kind: 'FootballMatchInvalidDate', + message, + })) + .map((dateTimeISOString) => ({ + kind: 'Fixture', + paId: feFixture.id, + kickOff: dateTimeISOString, + venue: feFixture.venue?.name, + homeTeam: { + name: cleanTeamName(feFixture.homeTeam.name), + paID: feFixture.homeTeam.id, + }, + awayTeam: { + name: cleanTeamName(feFixture.awayTeam.name), + paID: feFixture.awayTeam.id, + }, + })); +}; + +const parseMatchResult = ( + feResult: FEResult | FEMatchDay, +): Result => { + if (feResult.type === 'MatchDay' && !feResult.result) { + return error({ + kind: 'ExpectedMatchDayResult', + message: `A result match day must have 'result' set to 'true', but this was not the case for ${feResult.id}`, + }); + } + + return parseMatchDate(feResult.date) + .mapError((message) => ({ + kind: 'FootballMatchInvalidDate', + message, + })) + .map((dateTimeISOString) => ({ + kind: 'Result', + paId: feResult.id, + kickOff: dateTimeISOString, + venue: feResult.venue?.name, + homeTeam: { + name: cleanTeamName(feResult.homeTeam.name), + paID: feResult.homeTeam.id, + score: feResult.homeTeam.score ?? 0, // TODO + scorers: feResult.homeTeam.scorers?.split(',') ?? [], // TODO + }, + awayTeam: { + name: cleanTeamName(feResult.awayTeam.name), + paID: feResult.awayTeam.id, + score: feResult.awayTeam.score ?? 0, // TODO + scorers: feResult.awayTeam.scorers?.split(',') ?? [], // TOTO + }, + comment: feResult.comments, + })); +}; + +const parseLiveMatch = ( + feMatchDay: FEMatchDay, +): Result => { + if (!feMatchDay.liveMatch) { + return error({ + kind: 'ExpectedMatchDayLive', + message: `A live match day must have 'liveMatch' set to 'true', but this was not the case for ${feMatchDay.id}`, + }); + } + + return parseMatchDate(feMatchDay.date) + .mapError((message) => ({ + kind: 'FootballMatchInvalidDate', + message, + })) + .map((dateTimeISOString) => ({ + kind: 'Live', + paId: feMatchDay.id, + kickOff: dateTimeISOString, + venue: feMatchDay.venue?.name, + homeTeam: { + name: cleanTeamName(feMatchDay.homeTeam.name), + paID: feMatchDay.homeTeam.id, + score: feMatchDay.homeTeam.score ?? 0, // TODO + scorers: feMatchDay.homeTeam.scorers?.split(',') ?? [], // TOTO + }, + awayTeam: { + name: cleanTeamName(feMatchDay.awayTeam.name), + paID: feMatchDay.awayTeam.id, + score: feMatchDay.awayTeam.score ?? 0, // TODO + scorers: feMatchDay.awayTeam.scorers?.split(',') ?? [], // TOTO + }, + dateTimeISOString, + comment: cleanTeamName(feMatchDay.comments ?? ''), + status: replaceLiveMatchStatus(feMatchDay.matchStatus), + })); +}; + +const parseMatchDay = ( + feMatchDay: FEMatchDay, +): Result => + oneOf([ + parseLiveMatch, + parseMatchResult, + parseFixture, + ])(feMatchDay).mapError((errors) => ({ + kind: 'InvalidMatchDay', + errors, + })); + +export const parseFootballMatchV2 = ( + feMatch: FEFootballMatch, +): Result => { + switch (feMatch.type) { + case 'Fixture': + return parseFixture(feMatch); + case 'Result': + return parseMatchResult(feMatch); + case 'MatchDay': + return parseMatchDay(feMatch); + case 'LiveMatch': + return error({ + kind: 'UnexpectedLiveMatch', + message: + "Did not expect to get a match with type 'LiveMatch', allowed options are 'MatchDay', 'Fixture' or 'Result'", + }); + } +}; diff --git a/dotcom-rendering/src/frontend/feFootballMatchPage.ts b/dotcom-rendering/src/frontend/feFootballMatchPage.ts index 975456e8791..750003f5727 100644 --- a/dotcom-rendering/src/frontend/feFootballMatchPage.ts +++ b/dotcom-rendering/src/frontend/feFootballMatchPage.ts @@ -1,4 +1,5 @@ import type { FEFootballDataPage } from './feFootballDataPage'; +import { type FEFootballMatch } from './feFootballMatchListPage'; import { type FEGroupSummary } from './feFootballTablesPage'; export type FEFootballPlayerEvent = { @@ -33,7 +34,7 @@ export type FEFootballTeam = { crest: string; }; -export type FEFootballMatch = { +export type FEFootballMatchStats = { id: string; homeTeam: FEFootballTeam; awayTeam: FEFootballTeam; @@ -42,6 +43,7 @@ export type FEFootballMatch = { }; export type FEFootballMatchPage = FEFootballDataPage & { - footballMatch: FEFootballMatch; + footballMatch: FEFootballMatchStats; + matchInfo: FEFootballMatch; group?: FEGroupSummary; }; diff --git a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx index 159fd61a2e7..7eaf8fddd15 100644 --- a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx +++ b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx @@ -74,16 +74,24 @@ const SportsPage = ({ /> ); case 'FootballMatchSummary': { - if (isInVariantGroup) { - return ( - - ); - } + return ( + + ); + // if (isInVariantGroup) { + // return ( + // + // ); + // } - return ; + // return ; } } }; diff --git a/dotcom-rendering/src/lib/parse.ts b/dotcom-rendering/src/lib/parse.ts index 11db6629dae..7a4ad963534 100644 --- a/dotcom-rendering/src/lib/parse.ts +++ b/dotcom-rendering/src/lib/parse.ts @@ -9,3 +9,40 @@ export const parseIntResult = (int: string): Result => { return ok(parsed); }; + +export const parseDate = (a: string): Result => { + const d = new Date(a); + + if (d.toString() === 'Invalid Date') { + return error(`${String(a)} isn't a valid Date`); + } + + return ok(d); +}; + +export type Parser = (a: A) => Result; + +export const oneOf = + (parsers: [Parser, ...Array>]) => + (input: A): Result => { + const f = ( + remainingParsers: Array>, + errs: E[], + ): Result => { + const [head, ...tail] = remainingParsers; + + if (head === undefined) { + return error(errs); + } + + const result = head(input); + + if (!result.ok) { + return f(tail, [...errs, result.error]); + } + + return ok(result.value); + }; + + return f(parsers, []); + }; diff --git a/dotcom-rendering/src/server/handler.sportDataPage.web.ts b/dotcom-rendering/src/server/handler.sportDataPage.web.ts index 7be586a511f..39cd99665c5 100644 --- a/dotcom-rendering/src/server/handler.sportDataPage.web.ts +++ b/dotcom-rendering/src/server/handler.sportDataPage.web.ts @@ -6,6 +6,7 @@ import { parse as parseFootballMatches, } from '../footballMatches'; import { parseMatchStats } from '../footballMatchStats'; +import { parseFootballMatchV2 } from '../footballMatchV2'; import { parse as parseFootballTables, parseTableSummary, @@ -223,6 +224,7 @@ const parseFEFootballMatch = ( } const parsedFootballMatchStats = parseMatchStats(data.footballMatch); + const matchInfo = parseFootballMatchV2(data.matchInfo); if (!parsedFootballMatchStats.ok) { throw new Error( @@ -230,6 +232,13 @@ const parseFEFootballMatch = ( ); } + if (!matchInfo.ok) { + const aggeregatedErrors = getParserErrorMessage(matchInfo.error); + throw new Error( + `Failed to parse football match info: ${matchInfo.error.kind} ${aggeregatedErrors}`, + ); + } + const group = data.group && parseTableSummary(data.group); if (group && !group.ok) { @@ -241,6 +250,7 @@ const parseFEFootballMatch = ( return { match: parsedFootballMatch.value, matchStats: parsedFootballMatchStats.value, + matchInfo: matchInfo.value, group: group?.value, kind: 'FootballMatchSummary', nav: { diff --git a/dotcom-rendering/src/sportDataPage.ts b/dotcom-rendering/src/sportDataPage.ts index c96d8001012..7f90040bcfa 100644 --- a/dotcom-rendering/src/sportDataPage.ts +++ b/dotcom-rendering/src/sportDataPage.ts @@ -2,6 +2,7 @@ import type { CricketMatch } from './cricketMatch'; import type { FootballMatch } from './footballMatch'; import type { FootballMatches } from './footballMatches'; import { type FootballMatchStats } from './footballMatchStats'; +import { type FootballMatch as FootballMatchV2 } from './footballMatchV2'; import type { FootballTableCompetitions, FootballTableSummary, @@ -53,6 +54,7 @@ export type CricketMatchPage = SportPageConfig & { export type FootballMatchSummaryPage = SportPageConfig & { match: FootballMatch; matchStats: FootballMatchStats; + matchInfo: FootballMatchV2; group?: FootballTableSummary; kind: 'FootballMatchSummary'; }; From 65c01532b2a6db7c455ec0544b719d39a3a4a064 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Wed, 28 Jan 2026 09:48:07 +0000 Subject: [PATCH 2/8] populate match info page header with league name and edition --- .../src/components/FootballMatchInfoPage.tsx | 41 +++++++++++-------- .../src/frontend/feFootballMatchPage.ts | 1 + .../src/layouts/SportDataPageLayout.tsx | 2 + .../src/server/handler.sportDataPage.web.ts | 1 + dotcom-rendering/src/sportDataPage.ts | 1 + 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx index 198e3b36b4a..9f806dff57a 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx @@ -4,6 +4,7 @@ import { type FootballMatchStats } from '../footballMatchStats'; import { type FootballMatch } from '../footballMatchV2'; import { type FootballTableSummary } from '../footballTables'; import { grid } from '../grid'; +import { type EditionId } from '../lib/edition'; import { palette } from '../palette'; import { FootballMatchHeader } from './FootballMatchHeader/FootballMatchHeader'; import { FootballMatchInfo } from './FootballMatchInfo'; @@ -11,30 +12,36 @@ import { FootballMatchInfo } from './FootballMatchInfo'; export const FootballMatchInfoPage = ({ matchStats, matchInfo, + competitionName, + edition, table, }: { matchStats: FootballMatchStats; matchInfo: FootballMatch; + competitionName: string; + edition: EditionId; table?: FootballTableSummary; }) => { return ( -
-
- - +
+ +
+
+ +
); diff --git a/dotcom-rendering/src/frontend/feFootballMatchPage.ts b/dotcom-rendering/src/frontend/feFootballMatchPage.ts index 750003f5727..35f11901ffd 100644 --- a/dotcom-rendering/src/frontend/feFootballMatchPage.ts +++ b/dotcom-rendering/src/frontend/feFootballMatchPage.ts @@ -46,4 +46,5 @@ export type FEFootballMatchPage = FEFootballDataPage & { footballMatch: FEFootballMatchStats; matchInfo: FEFootballMatch; group?: FEGroupSummary; + competitionName: string; }; diff --git a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx index 7eaf8fddd15..4cec48386f1 100644 --- a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx +++ b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx @@ -78,6 +78,8 @@ const SportsPage = ({ ); diff --git a/dotcom-rendering/src/server/handler.sportDataPage.web.ts b/dotcom-rendering/src/server/handler.sportDataPage.web.ts index 39cd99665c5..8faba0d1aa2 100644 --- a/dotcom-rendering/src/server/handler.sportDataPage.web.ts +++ b/dotcom-rendering/src/server/handler.sportDataPage.web.ts @@ -251,6 +251,7 @@ const parseFEFootballMatch = ( match: parsedFootballMatch.value, matchStats: parsedFootballMatchStats.value, matchInfo: matchInfo.value, + competitionName: data.competitionName, group: group?.value, kind: 'FootballMatchSummary', nav: { diff --git a/dotcom-rendering/src/sportDataPage.ts b/dotcom-rendering/src/sportDataPage.ts index 7f90040bcfa..b840db973e7 100644 --- a/dotcom-rendering/src/sportDataPage.ts +++ b/dotcom-rendering/src/sportDataPage.ts @@ -56,6 +56,7 @@ export type FootballMatchSummaryPage = SportPageConfig & { matchStats: FootballMatchStats; matchInfo: FootballMatchV2; group?: FootballTableSummary; + competitionName: string; kind: 'FootballMatchSummary'; }; From ccd0dfe1a7549fbcde3f4e90687c32a0f90fa186 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Wed, 28 Jan 2026 10:17:02 +0000 Subject: [PATCH 3/8] fix match info page story --- .../fixtures/manual/footballData.ts | 21 +++++++++++++ .../FootballMatchInfoPage.stories.tsx | 4 +++ .../src/layouts/SportDataPageLayout.tsx | 31 +++++++------------ 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/dotcom-rendering/fixtures/manual/footballData.ts b/dotcom-rendering/fixtures/manual/footballData.ts index d8103fdb6da..c5a352f9c10 100644 --- a/dotcom-rendering/fixtures/manual/footballData.ts +++ b/dotcom-rendering/fixtures/manual/footballData.ts @@ -1,4 +1,5 @@ import type { FootballMatches } from '../../src/footballMatches'; +import type { FootballMatch } from '../../src/footballMatchV2'; import type { Region } from '../../src/sportDataPage'; export const regions: Region[] = [ @@ -20,6 +21,26 @@ export const regions: Region[] = [ }, ]; +export const footballMatchResult: FootballMatch = { + kind: 'Result', + kickOff: new Date('2022-01-01T11:11:00Z'), + paId: '4479251', + homeTeam: { + name: 'Germany', + paID: '7699', + score: 2, + scorers: ['Sjoeke Nusken 56 Pen', 'Lea Schuller 66'], + }, + awayTeam: { + name: 'Denmark', + paID: '35854', + score: 1, + scorers: ['Amalie Vangsgaard 26'], + }, + venue: 'St Jakob Park', + comment: '', +}; + export const initialDays: FootballMatches = [ { dateISOString: new Date('2022-01-01T00:00:00Z').toISOString(), diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx index a1ef6ee8a62..f3660ff2d7c 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; import { allModes } from '../../.storybook/modes'; +import { footballMatchResult } from '../../fixtures/manual/footballData'; import { table } from '../../fixtures/manual/footballTable'; import { matchStats } from '../../fixtures/manual/matchStats'; import { FootballMatchInfoPage as FootballMatchInfoPageComponent } from './FootballMatchInfoPage'; @@ -22,6 +23,9 @@ type Story = StoryObj; export const FootballMatchInfoPage = { args: { matchStats, + matchInfo: footballMatchResult, table, + competitionName: "Women's Euro 2025", + edition: 'UK', }, } satisfies Story; diff --git a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx index 4cec48386f1..8e71f8d9266 100644 --- a/dotcom-rendering/src/layouts/SportDataPageLayout.tsx +++ b/dotcom-rendering/src/layouts/SportDataPageLayout.tsx @@ -74,26 +74,19 @@ const SportsPage = ({ /> ); case 'FootballMatchSummary': { - return ( - - ); - // if (isInVariantGroup) { - // return ( - // - // ); - // } + if (isInVariantGroup) { + return ( + + ); + } - // return ; + return ; } } }; From 20eb171600d671b3c2c7825ad122b0621a2ef476 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Wed, 28 Jan 2026 16:30:22 +0000 Subject: [PATCH 4/8] populate missing prop for football header --- dotcom-rendering/src/components/FootballMatchInfoPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx index 9f806dff57a..70606ca76b0 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx @@ -31,6 +31,7 @@ export const FootballMatchInfoPage = ({ selected: 'info', reportURL: undefined, liveURL: undefined, + matchKind: matchInfo.kind, }} edition={edition} /> From 550141a7deddd34280ff0f65b55d9ef22a46b20c Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Fri, 30 Jan 2026 10:03:31 +0000 Subject: [PATCH 5/8] fix football parsers for score and scorers --- .../fixtures/manual/footballData.ts | 4 +- .../FootballMatchInfoPage.stories.tsx | 4 +- .../src/components/FootballMatchInfoPage.tsx | 13 ++++-- dotcom-rendering/src/footballMatchV2.ts | 44 +++++++++++++++---- .../src/frontend/feFootballMatchPage.ts | 4 ++ 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/dotcom-rendering/fixtures/manual/footballData.ts b/dotcom-rendering/fixtures/manual/footballData.ts index c5a352f9c10..123db198a44 100644 --- a/dotcom-rendering/fixtures/manual/footballData.ts +++ b/dotcom-rendering/fixtures/manual/footballData.ts @@ -1,5 +1,5 @@ import type { FootballMatches } from '../../src/footballMatches'; -import type { FootballMatch } from '../../src/footballMatchV2'; +import type { FootballMatch as FootballMatchV2 } from '../../src/footballMatchV2'; import type { Region } from '../../src/sportDataPage'; export const regions: Region[] = [ @@ -21,7 +21,7 @@ export const regions: Region[] = [ }, ]; -export const footballMatchResult: FootballMatch = { +export const footballMatchResultV2: FootballMatchV2 = { kind: 'Result', kickOff: new Date('2022-01-01T11:11:00Z'), paId: '4479251', diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx index f3660ff2d7c..0dfce5c6247 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-webpack5'; import { allModes } from '../../.storybook/modes'; -import { footballMatchResult } from '../../fixtures/manual/footballData'; +import { footballMatchResultV2 } from '../../fixtures/manual/footballData'; import { table } from '../../fixtures/manual/footballTable'; import { matchStats } from '../../fixtures/manual/matchStats'; import { FootballMatchInfoPage as FootballMatchInfoPageComponent } from './FootballMatchInfoPage'; @@ -23,7 +23,7 @@ type Story = StoryObj; export const FootballMatchInfoPage = { args: { matchStats, - matchInfo: footballMatchResult, + matchInfo: footballMatchResultV2, table, competitionName: "Women's Euro 2025", edition: 'UK', diff --git a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx index 70606ca76b0..263e1b3f3b2 100644 --- a/dotcom-rendering/src/components/FootballMatchInfoPage.tsx +++ b/dotcom-rendering/src/components/FootballMatchInfoPage.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { from } from '@guardian/source/foundations'; +import { from, space } from '@guardian/source/foundations'; import { type FootballMatchStats } from '../footballMatchStats'; import { type FootballMatch } from '../footballMatchV2'; import { type FootballTableSummary } from '../footballTables'; @@ -29,13 +29,14 @@ export const FootballMatchInfoPage = ({ match={matchInfo} tabs={{ selected: 'info', + matchKind: matchInfo.kind, + // We don't have these urls in the data yet. This will be fixed in upcoming PRs. reportURL: undefined, liveURL: undefined, - matchKind: matchInfo.kind, }} edition={edition} /> -
+
((message) => ({ kind: 'FootballMatchInvalidDate', @@ -187,14 +201,14 @@ const parseMatchResult = ( homeTeam: { name: cleanTeamName(feResult.homeTeam.name), paID: feResult.homeTeam.id, - score: feResult.homeTeam.score ?? 0, // TODO - scorers: feResult.homeTeam.scorers?.split(',') ?? [], // TODO + score: homeScore, + scorers: feResult.homeTeam.scorers?.split(',') ?? [], }, awayTeam: { name: cleanTeamName(feResult.awayTeam.name), paID: feResult.awayTeam.id, - score: feResult.awayTeam.score ?? 0, // TODO - scorers: feResult.awayTeam.scorers?.split(',') ?? [], // TOTO + score: awayScore, + scorers: feResult.awayTeam.scorers?.split(',') ?? [], }, comment: feResult.comments, })); @@ -210,6 +224,20 @@ const parseLiveMatch = ( }); } + if ( + feMatchDay.homeTeam.score === undefined || + feMatchDay.awayTeam.score === undefined + ) { + return error({ + kind: 'ExpectedMatchDayLive', + message: `A live match must have scores for both teams, but this was not the case for ${feMatchDay.id}`, + }); + } + + // Extract validated values so TypeScript can narrow the types + const homeScore = feMatchDay.homeTeam.score; + const awayScore = feMatchDay.awayTeam.score; + return parseMatchDate(feMatchDay.date) .mapError((message) => ({ kind: 'FootballMatchInvalidDate', @@ -223,14 +251,14 @@ const parseLiveMatch = ( homeTeam: { name: cleanTeamName(feMatchDay.homeTeam.name), paID: feMatchDay.homeTeam.id, - score: feMatchDay.homeTeam.score ?? 0, // TODO - scorers: feMatchDay.homeTeam.scorers?.split(',') ?? [], // TOTO + score: homeScore, + scorers: feMatchDay.homeTeam.scorers?.split(',') ?? [], }, awayTeam: { name: cleanTeamName(feMatchDay.awayTeam.name), paID: feMatchDay.awayTeam.id, - score: feMatchDay.awayTeam.score ?? 0, // TODO - scorers: feMatchDay.awayTeam.scorers?.split(',') ?? [], // TOTO + score: awayScore, + scorers: feMatchDay.awayTeam.scorers?.split(',') ?? [], }, dateTimeISOString, comment: cleanTeamName(feMatchDay.comments ?? ''), diff --git a/dotcom-rendering/src/frontend/feFootballMatchPage.ts b/dotcom-rendering/src/frontend/feFootballMatchPage.ts index 35f11901ffd..009a3ac2920 100644 --- a/dotcom-rendering/src/frontend/feFootballMatchPage.ts +++ b/dotcom-rendering/src/frontend/feFootballMatchPage.ts @@ -43,6 +43,10 @@ export type FEFootballMatchStats = { }; export type FEFootballMatchPage = FEFootballDataPage & { + // This field name will need to get changed to matchStats in the future PRs. + // Since this change needs to happen in both frontend and DCAR, and it also + // needs to be backward compatible for a temprary duration, we will handle + // that in a separate PR. footballMatch: FEFootballMatchStats; matchInfo: FEFootballMatch; group?: FEGroupSummary; From 3c565dd72b7838fd0779476bcd4baa9d17bb06d0 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki Date: Fri, 30 Jan 2026 10:05:19 +0000 Subject: [PATCH 6/8] generate new schema for FEFootballMatchPage --- .../frontend/schemas/feFootballMatchPage.json | 686 +++++++++++++++++- 1 file changed, 685 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/frontend/schemas/feFootballMatchPage.json b/dotcom-rendering/src/frontend/schemas/feFootballMatchPage.json index fdc9d4a4d27..6180816bdb6 100644 --- a/dotcom-rendering/src/frontend/schemas/feFootballMatchPage.json +++ b/dotcom-rendering/src/frontend/schemas/feFootballMatchPage.json @@ -295,6 +295,9 @@ "status" ] }, + "matchInfo": { + "$ref": "#/definitions/FEFootballMatch" + }, "group": { "type": "object", "properties": { @@ -473,10 +476,15 @@ "entries", "round" ] + }, + "competitionName": { + "type": "string" } }, "required": [ - "footballMatch" + "competitionName", + "footballMatch", + "matchInfo" ] } ], @@ -1045,6 +1053,682 @@ "text", "url" ] + }, + "FEFootballMatch": { + "anyOf": [ + { + "$ref": "#/definitions/FELive" + }, + { + "$ref": "#/definitions/FEFixture" + }, + { + "$ref": "#/definitions/FEMatchDay" + }, + { + "$ref": "#/definitions/FEResult" + } + ] + }, + "FELive": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "date": { + "type": "string" + }, + "stage": { + "type": "object", + "properties": { + "stageNumber": { + "type": "string" + } + }, + "required": [ + "stageNumber" + ] + }, + "round": { + "type": "object", + "properties": { + "roundNumber": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "roundNumber" + ] + }, + "leg": { + "type": "string" + }, + "homeTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "awayTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "venue": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "comments": { + "type": "string" + } + }, + "required": [ + "awayTeam", + "date", + "homeTeam", + "id", + "leg", + "round", + "stage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "LiveMatch" + }, + "status": { + "type": "string" + }, + "attendance": { + "type": "string" + }, + "referee": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "required": [ + "status", + "type" + ] + } + ] + }, + "FEFixture": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "date": { + "type": "string" + }, + "stage": { + "type": "object", + "properties": { + "stageNumber": { + "type": "string" + } + }, + "required": [ + "stageNumber" + ] + }, + "round": { + "type": "object", + "properties": { + "roundNumber": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "roundNumber" + ] + }, + "leg": { + "type": "string" + }, + "homeTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "awayTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "venue": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "comments": { + "type": "string" + } + }, + "required": [ + "awayTeam", + "date", + "homeTeam", + "id", + "leg", + "round", + "stage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Fixture" + }, + "competition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "FEMatchDay": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "date": { + "type": "string" + }, + "stage": { + "type": "object", + "properties": { + "stageNumber": { + "type": "string" + } + }, + "required": [ + "stageNumber" + ] + }, + "round": { + "type": "object", + "properties": { + "roundNumber": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "roundNumber" + ] + }, + "leg": { + "type": "string" + }, + "homeTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "awayTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "venue": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "comments": { + "type": "string" + } + }, + "required": [ + "awayTeam", + "date", + "homeTeam", + "id", + "leg", + "round", + "stage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "MatchDay" + }, + "liveMatch": { + "type": "boolean" + }, + "result": { + "type": "boolean" + }, + "previewAvailable": { + "type": "boolean" + }, + "reportAvailable": { + "type": "boolean" + }, + "lineupsAvailable": { + "type": "boolean" + }, + "matchStatus": { + "type": "string" + }, + "attendance": { + "type": "string" + }, + "referee": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "competition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "required": [ + "lineupsAvailable", + "liveMatch", + "matchStatus", + "previewAvailable", + "reportAvailable", + "result", + "type" + ] + } + ] + }, + "FEResult": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "date": { + "type": "string" + }, + "stage": { + "type": "object", + "properties": { + "stageNumber": { + "type": "string" + } + }, + "required": [ + "stageNumber" + ] + }, + "round": { + "type": "object", + "properties": { + "roundNumber": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "roundNumber" + ] + }, + "leg": { + "type": "string" + }, + "homeTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "awayTeam": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "score": { + "type": "number" + }, + "htScore": { + "type": "number" + }, + "aggregateScore": { + "type": "number" + }, + "scorers": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "venue": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "comments": { + "type": "string" + } + }, + "required": [ + "awayTeam", + "date", + "homeTeam", + "id", + "leg", + "round", + "stage" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Result" + }, + "reportAvailable": { + "type": "boolean" + }, + "attendance": { + "type": "string" + }, + "referee": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "required": [ + "reportAvailable", + "type" + ] + } + ] } }, "$schema": "http://json-schema.org/draft-07/schema#" From b9a6df80c888ded79f2c15adb10d02770f6336ad Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:54:56 +0000 Subject: [PATCH 7/8] Address comments in review --- .../src/components/FootballMatchHeader/FootballMatchHeader.tsx | 2 +- dotcom-rendering/src/lib/parse.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx index e6b937e1b1a..ffd6aab3133 100644 --- a/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx +++ b/dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx @@ -86,7 +86,7 @@ const StatusLine = (props: { }} > {props.leagueName} - {props.match.venue} •{' '} + {props.match.venue ? `${props.match.venue} • ` : null}

); diff --git a/dotcom-rendering/src/lib/parse.ts b/dotcom-rendering/src/lib/parse.ts index 7a4ad963534..285423afd08 100644 --- a/dotcom-rendering/src/lib/parse.ts +++ b/dotcom-rendering/src/lib/parse.ts @@ -14,7 +14,7 @@ export const parseDate = (a: string): Result => { const d = new Date(a); if (d.toString() === 'Invalid Date') { - return error(`${String(a)} isn't a valid Date`); + return error(`${a} isn't a valid Date`); } return ok(d); From a2f49e5ec9d154b96a0405d5b878768e033e5426 Mon Sep 17 00:00:00 2001 From: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:56:16 +0000 Subject: [PATCH 8/8] Use undefined for comment rather than empty string Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> --- dotcom-rendering/fixtures/manual/footballData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/fixtures/manual/footballData.ts b/dotcom-rendering/fixtures/manual/footballData.ts index 123db198a44..7f6f2e4bd3e 100644 --- a/dotcom-rendering/fixtures/manual/footballData.ts +++ b/dotcom-rendering/fixtures/manual/footballData.ts @@ -38,7 +38,7 @@ export const footballMatchResultV2: FootballMatchV2 = { scorers: ['Amalie Vangsgaard 26'], }, venue: 'St Jakob Park', - comment: '', + comment: undefined, }; export const initialDays: FootballMatches = [