Skip to content

Commit f410ed2

Browse files
jamesmockettmarjisoundJamieB-gu
authored
Football match header polling (#15350)
* Add a wrapper importable for FootballMatchHeader Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> Co-authored-by: James Mockett <1166188+jamesmockett@users.noreply.github.com> * Replace `FEFootballMatch` type with Valibot schema Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> * Use schema type for `FEMatchDayTeam` Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> * Use `variant` for union Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> * Fetch match header data Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> * Refactor fetching and parsing football header data Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> * Use SWR for polling football header data Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com> * Parse match header URL and pass through to header component `FootballMatchHeader` is also now wrapped inside an `Island` so the client side fetch is functional Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> * Moved SWR into main header component The side-effectful fetch function is now passed in, which means we can pass a mock version in storybook for testing. Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> * Update home team ID and use unique match header URLs in fixtures Co-authored-by: Marjan K <15894063+marjisound@users.noreply.github.com> * Added storybook interaction tests for football match header Co-authored-by: James M <1166188+jamesmockett@users.noreply.github.com> * Fix redeclared vars and void assertions * Don't use `waitFor` to check that the UI has updated As the before and after state for the 'Fixture' story is the same the expectations will pass before the UI has updated as `waitFor` does not have to wait and retry. By awaiting the change to the team name we ensure the UI has actually been updated before testing it is correct. --------- Co-authored-by: Marjan Kalanaki <15894063+marjisound@users.noreply.github.com> Co-authored-by: Jamie B <53781962+JamieB-gu@users.noreply.github.com>
1 parent e8fc993 commit f410ed2

8 files changed

Lines changed: 353 additions & 53 deletions

File tree

dotcom-rendering/fixtures/manual/footballMatches.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const matchData = {
1717
},
1818
leg: '1',
1919
homeTeam: {
20-
id: '1',
20+
id: '44',
2121
name: 'Home Team',
2222
},
2323
awayTeam: {
@@ -66,7 +66,7 @@ export const matchDayLive: FEMatchDay = {
6666
},
6767
awayTeam: {
6868
...matchData.awayTeam,
69-
score: 3,
69+
score: 4,
7070
},
7171
};
7272

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type ComponentProps } from 'react';
2+
import { type FootballMatch } from '../footballMatchV2';
3+
import { type EditionId } from '../lib/edition';
4+
import { FootballMatchHeader as FootballMatchHeaderComponent } from './FootballMatchHeader/FootballMatchHeader';
5+
6+
type Props = {
7+
leagueName: string;
8+
match: FootballMatch;
9+
tabs: ComponentProps<typeof FootballMatchHeaderComponent>['tabs'];
10+
edition: EditionId;
11+
matchHeaderURL: URL;
12+
};
13+
14+
export const FootballMatchHeader = (props: Props) => (
15+
<FootballMatchHeaderComponent
16+
leagueName={props.leagueName}
17+
match={props.match}
18+
tabs={props.tabs}
19+
edition={props.edition}
20+
matchHeaderURL={props.matchHeaderURL}
21+
getHeaderData={getHeaderData}
22+
refreshInterval={16_000}
23+
/>
24+
);
25+
26+
const getHeaderData = (url: string): Promise<unknown> =>
27+
fetch(url).then((res) => res.json());

dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.stories.tsx

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,44 @@
11
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import { expect, within } from 'storybook/test';
3+
import { SWRConfig } from 'swr';
4+
import {
5+
matchDayLive,
6+
matchFixture,
7+
matchResult,
8+
} from '../../../fixtures/manual/footballMatches';
9+
import type { FEFootballMatchHeader } from '../../frontend/feFootballMatchHeader';
210
import { FootballMatchHeader as FootballMatchHeaderComponent } from './FootballMatchHeader';
311

412
const meta = {
513
component: FootballMatchHeaderComponent,
14+
decorators: [
15+
(Story) => (
16+
// This resets the SWR cache on every story
17+
<SWRConfig value={{ provider: () => new Map() }}>
18+
<Story />
19+
</SWRConfig>
20+
),
21+
],
622
} satisfies Meta<typeof FootballMatchHeaderComponent>;
723

824
export default meta;
925

26+
const feHeaderData: FEFootballMatchHeader = {
27+
footballMatch: matchFixture,
28+
competitionName: 'Premier League',
29+
liveURL:
30+
'https://www.theguardian.com/football/live/2025/nov/26/arsenal-v-bayern-munich-champions-league-live',
31+
reportURL:
32+
'https://www.theguardian.com/football/2025/nov/26/arsenal-bayern-munich-champions-league-match-report',
33+
infoURL:
34+
'https://www.theguardian.com/football/match/2025/nov/26/arsenal-v-bayernmunich',
35+
};
36+
1037
type Story = StoryObj<typeof meta>;
1138

1239
export const Fixture = {
1340
args: {
14-
leagueName: 'Premier League',
41+
leagueName: feHeaderData.competitionName,
1542
match: {
1643
kind: 'Fixture',
1744
kickOff: new Date('2025-11-05T20:30:00Z'),
@@ -31,12 +58,39 @@ export const Fixture = {
3158
matchKind: 'Fixture',
3259
},
3360
edition: 'UK',
61+
getHeaderData: () =>
62+
getMockData({
63+
...feHeaderData,
64+
liveURL: undefined,
65+
reportURL: undefined,
66+
}),
67+
refreshInterval: 3_000,
68+
matchHeaderURL: new URL(
69+
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
70+
),
71+
},
72+
play: async ({ canvas, step }) => {
73+
const nav = canvas.getByRole('navigation');
74+
const initialTabs = within(nav).getAllByRole('listitem');
75+
76+
void expect(initialTabs.length).toBe(1);
77+
void expect(initialTabs[0]).toHaveTextContent('Match info');
78+
79+
await step('Fetch updated match header data', async () => {
80+
// Wait for 'Home Team' to appear which indicates match header data
81+
// has been fetched and the UI updated
82+
await canvas.findByText('Home Team');
83+
84+
const updatedTabs = within(nav).getAllByRole('listitem');
85+
void expect(updatedTabs.length).toBe(1);
86+
void expect(updatedTabs[0]).toHaveTextContent('Match info');
87+
});
3488
},
3589
} satisfies Story;
3690

3791
export const Live = {
3892
args: {
39-
leagueName: Fixture.args.leagueName,
93+
leagueName: feHeaderData.competitionName,
4094
match: {
4195
...Fixture.args.match,
4296
kind: 'Live',
@@ -65,6 +119,29 @@ export const Live = {
65119
),
66120
},
67121
edition: 'EUR',
122+
matchHeaderURL: new URL(
123+
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
124+
),
125+
refreshInterval: Fixture.args.refreshInterval,
126+
getHeaderData: () =>
127+
getMockData({
128+
...feHeaderData,
129+
footballMatch: matchDayLive,
130+
reportURL: undefined,
131+
}),
132+
},
133+
play: async ({ canvas, step }) => {
134+
void expect(canvas.getByLabelText('Score: 0')).toBeInTheDocument();
135+
void expect(canvas.getByLabelText('Score: 13')).toBeInTheDocument();
136+
137+
await step('Fetch updated match header data', async () => {
138+
// Wait for 'Home Team' to appear which indicates match header data
139+
// has been fetched and the UI updated
140+
await canvas.findByText('Home Team');
141+
142+
void expect(canvas.getByLabelText('Score: 3')).toBeInTheDocument();
143+
void expect(canvas.getByLabelText('Score: 4')).toBeInTheDocument();
144+
});
68145
},
69146
} satisfies Story;
70147

@@ -78,13 +155,43 @@ export const Result = {
78155
tabs: {
79156
selected: 'info',
80157
matchKind: 'Result',
81-
liveURL: new URL(
82-
'https://www.theguardian.com/football/live/2025/nov/26/arsenal-v-bayern-munich-champions-league-live',
83-
),
84-
reportURL: new URL(
85-
'https://www.theguardian.com/football/2025/nov/26/arsenal-bayern-munich-champions-league-match-report',
86-
),
87158
},
88159
edition: 'AU',
160+
matchHeaderURL: new URL(
161+
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
162+
),
163+
refreshInterval: Fixture.args.refreshInterval,
164+
getHeaderData: () =>
165+
getMockData({
166+
...feHeaderData,
167+
footballMatch: matchResult,
168+
}),
169+
},
170+
171+
play: async ({ canvas, step }) => {
172+
const nav = canvas.getByRole('navigation');
173+
const initialTabs = within(nav).getAllByRole('listitem');
174+
175+
void expect(initialTabs.length).toBe(1);
176+
void expect(initialTabs[0]).toHaveTextContent('Match info');
177+
178+
await step('Fetch updated match header data', async () => {
179+
// Wait for 'Home Team' to appear which indicates match header data
180+
// has been fetched and the UI updated
181+
await canvas.findByText('Home Team');
182+
183+
const updatedTabs = within(nav).getAllByRole('listitem');
184+
void expect(updatedTabs.length).toBe(3);
185+
void expect(updatedTabs[0]).toHaveTextContent('Match report');
186+
void expect(updatedTabs[1]).toHaveTextContent('Live feed');
187+
void expect(updatedTabs[2]).toHaveTextContent('Match info');
188+
});
89189
},
90190
} satisfies Story;
191+
192+
const getMockData = (data: FEFootballMatchHeader) =>
193+
new Promise((resolve) => {
194+
setTimeout(() => {
195+
resolve(data);
196+
}, 1000);
197+
});

dotcom-rendering/src/components/FootballMatchHeader/FootballMatchHeader.tsx

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { css } from '@emotion/react';
2+
import { log } from '@guardian/libs';
23
import {
34
from,
45
headlineBold20Object,
@@ -11,6 +12,8 @@ import {
1112
until,
1213
} from '@guardian/source/foundations';
1314
import { type ComponentProps, type ReactNode, useMemo } from 'react';
15+
import type { SWRConfiguration } from 'swr';
16+
import useSWR from 'swr';
1417
import type { FootballMatch } from '../../footballMatchV2';
1518
import { grid } from '../../grid';
1619
import {
@@ -23,47 +26,93 @@ import type { ColourName } from '../../paletteDeclarations';
2326
import { BigNumber } from '../BigNumber';
2427
import { FootballCrest } from '../FootballCrest';
2528
import { background, border, primaryText, secondaryText } from './colours';
29+
import { type HeaderData, parse as parseHeaderData } from './headerData';
2630
import { Tabs } from './Tabs';
2731

2832
type Props = {
2933
leagueName: string;
3034
match: FootballMatch;
3135
tabs: ComponentProps<typeof Tabs>;
3236
edition: EditionId;
37+
matchHeaderURL: URL;
38+
getHeaderData: (url: string) => Promise<unknown>;
39+
refreshInterval: number;
3340
};
3441

35-
export const FootballMatchHeader = (props: Props) => (
36-
<section
37-
style={{
38-
backgroundColor: palette(background(props.match.kind)),
39-
color: palette(primaryText(props.match.kind)),
40-
}}
41-
>
42-
<div
43-
css={{
44-
'&': css(grid.paddedContainer),
45-
[from.tablet]: {
46-
borderColor: palette(
47-
'--football-match-header-fixture-result-border',
48-
),
49-
borderStyle: 'solid',
50-
borderLeftWidth: 1,
51-
borderRightWidth: 1,
52-
},
42+
export const FootballMatchHeader = (props: Props) => {
43+
const { data } = useSWR<HeaderData, string>(
44+
props.matchHeaderURL,
45+
fetcher(props.tabs.selected, props.getHeaderData),
46+
swrOptions(props.refreshInterval),
47+
);
48+
49+
const match = data?.match ?? props.match;
50+
const tabs = data?.tabs ?? props.tabs;
51+
52+
return (
53+
<section
54+
style={{
55+
backgroundColor: palette(background(match.kind)),
56+
color: palette(primaryText(match.kind)),
5357
}}
5458
>
55-
<StatusLine
56-
leagueName={props.leagueName}
57-
match={props.match}
58-
edition={props.edition}
59-
/>
60-
<Hr borderStyle="dotted" borderColour={border(props.match.kind)} />
61-
<Teams match={props.match} />
62-
<Hr borderStyle="solid" borderColour={border(props.match.kind)} />
63-
<Tabs {...props.tabs} />
64-
</div>
65-
</section>
66-
);
59+
<div
60+
css={{
61+
'&': css(grid.paddedContainer),
62+
[from.tablet]: {
63+
borderColor: palette(
64+
'--football-match-header-fixture-result-border',
65+
),
66+
borderStyle: 'solid',
67+
borderLeftWidth: 1,
68+
borderRightWidth: 1,
69+
},
70+
}}
71+
>
72+
<StatusLine
73+
leagueName={props.leagueName}
74+
match={match}
75+
edition={props.edition}
76+
/>
77+
<Hr borderStyle="dotted" borderColour={border(match.kind)} />
78+
<Teams match={match} />
79+
<Hr borderStyle="solid" borderColour={border(match.kind)} />
80+
<Tabs {...tabs} />
81+
</div>
82+
</section>
83+
);
84+
};
85+
86+
const swrOptions = (refreshInterval: number): SWRConfiguration<HeaderData> => ({
87+
errorRetryCount: 1,
88+
refreshInterval: (latestData: HeaderData | undefined) => {
89+
return latestData?.match.kind === 'Live' ||
90+
latestData?.match.kind === 'Fixture'
91+
? refreshInterval
92+
: 0;
93+
},
94+
});
95+
96+
const fetcher =
97+
(
98+
selected: Props['tabs']['selected'],
99+
getHeaderData: Props['getHeaderData'],
100+
) =>
101+
(url: string): Promise<HeaderData> =>
102+
getHeaderData(url)
103+
.then(parseHeaderData(selected))
104+
.then((result) => {
105+
if (!result.ok) {
106+
log('dotcom', result.error);
107+
throw new Error();
108+
} else {
109+
return result.value;
110+
}
111+
})
112+
.catch(() => {
113+
log('dotcom', 'Failed to fetch match header json');
114+
throw new Error();
115+
});
67116

68117
const StatusLine = (props: {
69118
leagueName: string;

0 commit comments

Comments
 (0)