Skip to content

Commit d8beb4e

Browse files
authored
Add notifications to football match header (#15720)
The header now takes a `NotificationsClient` as a prop. The live version of this is passed from the wrapper component, and a mock version is passed in the stories. There are also some small changes to the stories to ensure that a mix of apps and web versions of the header are captured, demonstrating that notifications are correctly shown on apps and not on web. For the notification payload sent via Bridget a "display name" is required, which specifies the two teams and the date of the match. This change therefore includes a function to calculate this from the match information, and some tests to ensure this works as expected.
1 parent d52f569 commit d8beb4e

6 files changed

Lines changed: 141 additions & 26 deletions

File tree

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
matchResult,
88
} from '../../../fixtures/manual/footballMatches';
99
import type { FEFootballMatchHeader } from '../../frontend/feFootballMatchHeader';
10+
import { NotificationsToggle } from '../NotificationsToggle.stories';
1011
import { FootballMatchHeader as FootballMatchHeaderComponent } from './FootballMatchHeader';
1112

1213
const meta = preview.meta({
@@ -32,7 +33,7 @@ const feHeaderData: FEFootballMatchHeader = {
3233
'https://www.theguardian.com/football/match/2025/nov/26/arsenal-v-bayernmunich',
3334
};
3435

35-
export const Fixture = meta.story({
36+
export const FixtureWeb = meta.story({
3637
args: {
3738
initialTab: 'info',
3839
initialData: {
@@ -67,6 +68,7 @@ export const Fixture = meta.story({
6768
matchHeaderURL:
6869
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
6970
renderingTarget: 'Web',
71+
notificationsClient: NotificationsToggle.args.notificationsClient,
7072
},
7173
play: async ({ canvas, canvasElement, step }) => {
7274
const nav = canvas.getByRole('navigation');
@@ -99,22 +101,23 @@ export const Fixture = meta.story({
99101
},
100102
});
101103

102-
export const Live = meta.story({
104+
export const LiveApps = meta.story({
103105
args: {
104106
initialTab: 'live',
105107
leagueName: 'Premier League',
106108
leagueURL: 'https://www.theguardian.com/football/premierleague',
107109
edition: 'EUR',
108110
matchHeaderURL:
109111
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
110-
refreshInterval: Fixture.input.args.refreshInterval,
112+
refreshInterval: FixtureWeb.input.args.refreshInterval,
111113
getHeaderData: () =>
112114
getMockData({
113115
...feHeaderData,
114116
footballMatch: matchDayLive,
115117
reportURL: undefined,
116118
}),
117-
renderingTarget: 'Web',
119+
renderingTarget: 'Apps',
120+
notificationsClient: NotificationsToggle.args.notificationsClient,
118121
},
119122
play: async ({ canvas, canvasElement, step }) => {
120123
await step(
@@ -144,23 +147,29 @@ export const Live = meta.story({
144147
void expect(tabs[1]).toHaveTextContent('Match info');
145148
});
146149
},
150+
parameters: {
151+
config: {
152+
renderingTarget: 'Apps',
153+
},
154+
},
147155
});
148156

149-
export const Result = meta.story({
157+
export const ResultWeb = meta.story({
150158
args: {
151159
initialTab: 'report',
152160
leagueName: 'Premier League',
153161
leagueURL: 'https://www.theguardian.com/football/premierleague',
154162
edition: 'AU',
155163
matchHeaderURL:
156164
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
157-
refreshInterval: Fixture.input.args.refreshInterval,
165+
refreshInterval: FixtureWeb.input.args.refreshInterval,
158166
getHeaderData: () =>
159167
getMockData({
160168
...feHeaderData,
161169
footballMatch: matchResult,
162170
}),
163171
renderingTarget: 'Web',
172+
notificationsClient: NotificationsToggle.args.notificationsClient,
164173
},
165174

166175
play: async ({ canvas, canvasElement, step }) => {
@@ -191,21 +200,22 @@ export const Result = meta.story({
191200
},
192201
});
193202

194-
export const AppsResult = meta.story({
203+
export const ResultApps = meta.story({
195204
args: {
196205
initialTab: 'report',
197206
leagueName: 'Premier League',
198207
leagueURL: 'https://www.theguardian.com/football/premierleague',
199208
edition: 'AU',
200209
matchHeaderURL:
201210
'https://api.nextgen.guardianapps.co.uk/football/api/match-header/2026/02/08/26247/48490.json',
202-
refreshInterval: Fixture.input.args.refreshInterval,
211+
refreshInterval: FixtureWeb.input.args.refreshInterval,
203212
getHeaderData: () =>
204213
getMockData({
205214
...feHeaderData,
206215
footballMatch: matchResult,
207216
}),
208217
renderingTarget: 'Apps',
218+
notificationsClient: NotificationsToggle.args.notificationsClient,
209219
},
210220

211221
play: async ({ canvas, canvasElement, step }) => {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import useSWR from 'swr';
1919
import type { FootballMatch } from '../../footballMatchV2';
2020
import { grid } from '../../grid';
2121
import { ArticleDesign, type ArticleFormat } from '../../lib/articleFormat';
22+
import type { NotificationsClient } from '../../lib/bridgetApi';
2223
import {
2324
type EditionId,
2425
getLocaleFromEdition,
@@ -34,6 +35,7 @@ import { background, border, primaryText, secondaryText } from './colours';
3435
import { FootballMatchHeaderFallback } from './FootballMatchHeaderFallback';
3536
import { type HeaderData, parse as parseHeaderData } from './headerData';
3637
import { Hr } from './Hr';
38+
import { Notifications } from './Notifications';
3739
import { Tabs } from './Tabs';
3840

3941
export type FootballMatchHeaderProps = {
@@ -51,6 +53,7 @@ export type FootballMatchHeaderProps = {
5153
type Props = FootballMatchHeaderProps & {
5254
getHeaderData: (url: string) => Promise<unknown>;
5355
refreshInterval: number;
56+
notificationsClient: NotificationsClient;
5457
};
5558

5659
export const FootballMatchHeader = (props: Props) => {
@@ -122,6 +125,11 @@ export const FootballMatchHeader = (props: Props) => {
122125
<Hr borderStyle="dotted" borderColour={border(match.kind)} />
123126
<Teams match={match} />
124127
<Comment match={match} />
128+
<Notifications
129+
edition={props.edition}
130+
match={match}
131+
notificationsClient={props.notificationsClient}
132+
/>
125133
<Hr borderStyle="solid" borderColour={border(match.kind)} />
126134
<Tabs {...tabs} />
127135
</div>

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import preview from '../../../.storybook/preview';
33
import { palette } from '../../palette';
44
import { NotificationsToggle } from '../NotificationsToggle.stories';
55
import { background } from './colours';
6+
import { FixtureWeb } from './FootballMatchHeader.stories';
67
import { Notifications } from './Notifications';
78

89
const meta = preview.meta({
@@ -11,9 +12,8 @@ const meta = preview.meta({
1112

1213
export const Fixture = meta.story({
1314
args: {
14-
matchKind: 'Fixture',
15-
displayName: 'Wolverhampton Wanderers vs Belgium (2026-03-20 GMT)',
16-
id: 'match-id',
15+
match: FixtureWeb.input.args.initialData.match,
16+
edition: 'UK',
1717
notificationsClient: NotificationsToggle.args.notificationsClient,
1818
},
1919
decorators: [gridContainerDecorator],
@@ -30,7 +30,23 @@ export const Fixture = meta.story({
3030

3131
export const Live = Fixture.extend({
3232
args: {
33-
matchKind: 'Live',
33+
edition: 'AU',
34+
match: {
35+
...Fixture.input.args.match,
36+
kind: 'Live',
37+
status: 'HT',
38+
homeTeam: {
39+
...Fixture.input.args.match.homeTeam,
40+
score: 1,
41+
scorers: [],
42+
},
43+
awayTeam: {
44+
...Fixture.input.args.match.awayTeam,
45+
score: 0,
46+
scorers: [],
47+
},
48+
comment: undefined,
49+
},
3450
},
3551
parameters: {
3652
colourSchemeBackground: {
@@ -44,8 +60,12 @@ export const Live = Fixture.extend({
4460
* This should be blank. You can't sign up for notifications once a match is
4561
* over.
4662
*/
47-
export const Result = Fixture.extend({
63+
export const Result = Live.extend({
4864
args: {
49-
matchKind: 'Result',
65+
edition: 'US',
66+
match: {
67+
...Live.input.args.match,
68+
kind: 'Result',
69+
},
5070
},
5171
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { FootballMatch } from '../../footballMatchV2';
2+
import { notificationDisplayName } from './Notifications';
3+
4+
const matchFixture = {
5+
kind: 'Fixture',
6+
kickOff: new Date('2025-11-20T20:30:00Z'),
7+
venue: 'Old Trafford',
8+
homeTeam: {
9+
name: 'Wolverhampton Wanderers',
10+
paID: '44',
11+
},
12+
awayTeam: {
13+
name: 'Belgium',
14+
paID: '997',
15+
},
16+
paId: 'matchId',
17+
} satisfies FootballMatch;
18+
19+
describe('notificationDisplayName', () => {
20+
it('puts the home team first, the away team second, and uses a UK date format', () => {
21+
const displayName = notificationDisplayName('UK')(matchFixture);
22+
23+
expect(displayName).toEqual(
24+
'Wolverhampton Wanderers vs Belgium (20/11/2025, GMT)',
25+
);
26+
});
27+
28+
it('puts the home team first, the away team second, and uses the correct day and date format for AU', () => {
29+
const displayName = notificationDisplayName('AU')(matchFixture);
30+
31+
expect(displayName).toEqual(
32+
'Wolverhampton Wanderers vs Belgium (21/11/2025, AEDT)',
33+
);
34+
});
35+
36+
it('puts the home team first, the away team second, and uses a US date format', () => {
37+
const displayName = notificationDisplayName('US')(matchFixture);
38+
39+
expect(displayName).toEqual(
40+
'Wolverhampton Wanderers vs Belgium (11/20/2025, EST)',
41+
);
42+
});
43+
});
Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
11
import { css } from '@emotion/react';
22
import { space, textSans14Object } from '@guardian/source/foundations';
3+
import { useMemo } from 'react';
34
import type { FootballMatch } from '../../footballMatchV2';
45
import { grid } from '../../grid';
56
import type { NotificationsClient } from '../../lib/bridgetApi';
7+
import {
8+
type EditionId,
9+
getLocaleFromEdition,
10+
getTimeZoneFromEdition,
11+
} from '../../lib/edition';
612
import { palette } from '../../palette';
713
import { useConfig } from '../ConfigContext';
814
import { NotificationsToggle } from '../NotificationsToggle';
915
import { background, border, primaryText } from './colours';
1016
import { Hr } from './Hr';
1117

1218
type Props = {
13-
matchKind: FootballMatch['kind'];
14-
displayName: string;
15-
id: string;
19+
match: FootballMatch;
20+
edition: EditionId;
1621
notificationsClient: NotificationsClient;
1722
};
1823

1924
export const Notifications = (props: Props) => {
2025
const { renderingTarget } = useConfig();
26+
// useMemo to limit constructions of `Intl.DateTimeFormat`
27+
const displayName = useMemo(
28+
() => notificationDisplayName(props.edition),
29+
[props.edition],
30+
);
2131

22-
if (renderingTarget !== 'Apps' || props.matchKind === 'Result') {
32+
if (renderingTarget !== 'Apps' || props.match.kind === 'Result') {
2333
return null;
2434
}
2535

2636
return (
2737
<>
28-
<Hr borderStyle="solid" borderColour={border(props.matchKind)} />
38+
<Hr borderStyle="solid" borderColour={border(props.match.kind)} />
2939
<p
3040
css={{
3141
...textSans14Object,
@@ -36,27 +46,49 @@ export const Notifications = (props: Props) => {
3646
paddingRight: 6,
3747
}}
3848
style={{
39-
color: palette(primaryText(props.matchKind)),
49+
color: palette(primaryText(props.match.kind)),
4050
}}
4151
>
4252
Be notified about the lineup, kick-off time, goals, half-time
4353
and full time scores
4454
</p>
4555
<NotificationsToggle
46-
displayName={props.displayName}
47-
id={props.id}
56+
displayName={displayName(props.match)}
57+
id={props.match.paId}
4858
notificationType="football-match"
4959
notificationsClient={props.notificationsClient}
50-
colour={primaryText(props.matchKind)}
51-
backgroundColour={background(props.matchKind)}
52-
iconColour={primaryText(props.matchKind)}
60+
colour={primaryText(props.match.kind)}
61+
backgroundColour={background(props.match.kind)}
62+
iconColour={primaryText(props.match.kind)}
5363
css={{
5464
'&': css(grid.column.centre),
5565
paddingLeft: 6,
5666
paddingRight: 6,
57-
paddingBottom: space[5],
67+
paddingBottom: space[4],
5868
}}
5969
/>
6070
</>
6171
);
6272
};
73+
74+
/**
75+
* Exported for the tests.
76+
*/
77+
export const notificationDisplayName =
78+
(edition: EditionId) =>
79+
(match: FootballMatch): string => {
80+
const homeTeam = match.homeTeam.name;
81+
const awayTeam = match.awayTeam.name;
82+
const date = matchDayFormatterForEdition(edition).format(match.kickOff);
83+
84+
return `${homeTeam} vs ${awayTeam} (${date})`;
85+
};
86+
87+
const matchDayFormatterForEdition = (edition: EditionId): Intl.DateTimeFormat =>
88+
new Intl.DateTimeFormat(getLocaleFromEdition(edition), {
89+
day: 'numeric',
90+
month: 'numeric',
91+
year: 'numeric',
92+
timeZoneName: 'short',
93+
timeZone: getTimeZoneFromEdition(edition),
94+
});

dotcom-rendering/src/components/FootballMatchHeaderWrapper.island.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getNotificationsClient } from '../lib/bridgetApi';
12
import type { FootballMatchHeaderProps } from './FootballMatchHeader/FootballMatchHeader';
23
import { FootballMatchHeader } from './FootballMatchHeader/FootballMatchHeader';
34
import type { HeaderData } from './FootballMatchHeader/headerData';
@@ -27,6 +28,7 @@ export const FootballMatchHeaderWrapper = (props: Props) => (
2728
getHeaderData={getHeaderData}
2829
refreshInterval={16_000}
2930
renderingTarget={props.renderingTarget}
31+
notificationsClient={getNotificationsClient()}
3032
/>
3133
);
3234

0 commit comments

Comments
 (0)