Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ab-testing/config/abTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,17 @@ const ABTests: ABTest[] = [
groups: ["control", "variant-1"],
shouldForceMetricsCollection: false,
},
{
name: "webx-cricket-redesign",
description: "Redesign of the cricket header and scorecard on web",
owners: ["dotcom.platform@theguardian.com"],
status: "ON",
expirationDate: "2026-08-01",
type: "server",
audienceSize: 0 / 100,
groups: ["enable"],
shouldForceMetricsCollection: false,
},
];

const activeABtests = ABTests.filter((test) => test.status === "ON");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { expect, within } from 'storybook/test';
import type { CricketMatch } from '../../cricketMatchV2';
import type { EditionId } from '../../lib/edition';
import { CricketMatchHeader } from './CricketMatchHeader';
Expand Down Expand Up @@ -89,7 +90,7 @@ const defaultInnings = {

const baseArgs = {
edition: 'UK' as EditionId,
match: {
initialData: {
kind: 'Fixture' as CricketMatch['kind'],
series: 'Ashes 2025–2026',
competition: 'Second Test Match',
Expand All @@ -112,17 +113,50 @@ const baseArgs = {
infoURL: new URL(
'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket#scorecard',
),
matchHeaderURL:
'https://api.nextgen.guardianapps.co.uk/sport/cricket/match-header/2026-06-13/australia-women-s-cricket-team.json',
refreshInterval: 3_000,
getHeaderData: () => Promise.resolve(undefined),
};

export const Fixture = {
args: baseArgs,
play: async ({ canvas, canvasElement, step }) => {
const nav = canvas.getByRole('navigation');

await step(
'Placeholder not shown as we have initial data and can render header',
async () => {
void expect(
canvasElement.querySelector('[data-name="placeholder"]'),
).toBeNull();

const initialTabs = within(nav).getAllByRole('listitem');

void expect(initialTabs.length).toBe(1);
void expect(initialTabs[0]).toHaveTextContent('Scorecard');
},
);

await step('Fetch updated match header data', async () => {
// Wait for 'Ashes 2025–2026' to appear which indicates match header
// data has been fetched and the UI updated on the client
await canvas.findByText('Ashes 2025–2026');
void canvas.findByText('England');
void canvas.findByText('Australia');

const updatedTabs = within(nav).getAllByRole('listitem');
void expect(updatedTabs.length).toBe(1);
void expect(updatedTabs[0]).toHaveTextContent('Scorecard');
});
},
} satisfies Story;

export const Live = {
args: {
...baseArgs,
match: {
...Fixture.args.match,
initialData: {
...Fixture.args.initialData,
kind: 'Live' as CricketMatch['kind'],
day: 2,
innings: [
Expand Down Expand Up @@ -160,15 +194,38 @@ export const Live = {
'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket',
),
},
play: async ({ canvas, step }) => {
await step('Fetch match header data and render UI', async () => {
// Wait for 'Ashes 2025–2026' to appear which signals match header
// data has been fetched and the UI rendered on the client
await canvas.findByText('Ashes 2025–2026');
void canvas.findByText('England');
void canvas.findByText('Australia');

void expect(
canvas.getByLabelText('169 runs, 0 wickets fallen'),
).toBeInTheDocument();
void expect(
canvas.getByLabelText('173 runs, 3 wickets fallen'),
).toBeInTheDocument();

const nav = canvas.getByRole('navigation');
const tabs = within(nav).getAllByRole('listitem');

void expect(tabs.length).toBe(2);
void expect(tabs[0]).toHaveTextContent('Live feed');
void expect(tabs[1]).toHaveTextContent('Scorecard');
});
},
} satisfies Story;

export const LiveYetToBat = {
name: 'Live (Team yet to bat)',
args: {
...baseArgs,
edition: 'UK' as EditionId,
match: {
...Fixture.args.match,
initialData: {
...Fixture.args.initialData,
kind: 'Live',
day: 2,
innings: [
Expand Down Expand Up @@ -200,8 +257,8 @@ export const Result = {
args: {
...baseArgs,
edition: 'UK' as EditionId,
match: {
...Fixture.args.match,
initialData: {
...Fixture.args.initialData,
kind: 'Result',
day: 4,
innings: [
Expand Down Expand Up @@ -264,14 +321,30 @@ export const Result = {
'https://www.theguardian.com/sport/live/2026/jan/27/australia-v-england-second-test-day-two-live-cricket',
),
},
play: async ({ canvas, step }) => {
await step('Fetch match header data and render UI', async () => {
// Wait for 'Ashes 2025–2026' to appear which signals match header
// data has been fetched and the UI rendered on the client
await canvas.findByText('Ashes 2025–2026');
void canvas.findByText('England');
void canvas.findByText('Australia');

const nav = canvas.getByRole('navigation');
const tabs = within(nav).getAllByRole('listitem');

void expect(tabs.length).toBe(2);
void expect(tabs[0]).toHaveTextContent('Live feed');
void expect(tabs[1]).toHaveTextContent('Scorecard');
});
},
} satisfies Story;

export const ResultWinByWickets = {
args: {
...baseArgs,
edition: 'UK' as EditionId,
match: {
...Fixture.args.match,
initialData: {
...Fixture.args.initialData,
kind: 'Result',
innings: [
{
Expand Down Expand Up @@ -321,8 +394,8 @@ export const ResultDrawn = {
args: {
...baseArgs,
edition: 'UK' as EditionId,
match: {
...Fixture.args.match,
initialData: {
...Fixture.args.initialData,
kind: 'Result',
day: 4,
innings: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { css } from '@emotion/react';
import { log } from '@guardian/libs';
import {
from,
headlineBold20Object,
Expand All @@ -14,6 +15,8 @@ import {
until,
} from '@guardian/source/foundations';
import { Fragment, type ReactNode, useMemo } from 'react';
import type { SWRConfiguration } from 'swr';
import useSWR from 'swr';
import type {
CricketMatch,
CricketResult,
Expand All @@ -35,19 +38,47 @@ import {
primaryText,
secondaryText,
} from '../FootballMatchHeader/colours';
import type { TabName } from '../FootballMatchHeader/Tabs';
import { Tabs } from '../FootballMatchHeader/Tabs';
import { Placeholder } from '../Placeholder';
import type { CricketHeaderData } from './headerData';
import { parse as parseHeaderData } from './headerData';

type Props = {
export type CricketMatchHeaderProps = {
initialData?: CricketMatch;
Comment thread
Jakeii marked this conversation as resolved.
matchHeaderURL: string;
edition: EditionId;
match: CricketMatch;
selectedTab: 'info' | 'live' | 'report';
reportURL?: URL;
liveURL?: URL;
infoURL?: URL;
};

type Props = CricketMatchHeaderProps & {
getHeaderData: (url: string) => Promise<unknown>;
refreshInterval: number;
};

export const CricketMatchHeader = (props: Props) => {
const match = props.match;
const { data } = useSWR<CricketHeaderData, Error>(
props.matchHeaderURL,
fetcher(props.selectedTab, props.getHeaderData),
swrOptions(props.refreshInterval),
);
const match = data?.match ?? props.initialData;

if (match === undefined) {
return (
<Placeholder
heights={
new Map([
['mobile', 182],
['leftCol', 172],
])
}
/>
);
}

return (
<section
Expand Down Expand Up @@ -91,6 +122,36 @@ export const CricketMatchHeader = (props: Props) => {
);
};

const swrOptions = (
refreshInterval: number,
): SWRConfiguration<CricketHeaderData> => ({
errorRetryCount: 1,
refreshInterval: (latestData: CricketHeaderData | undefined) => {
return latestData?.match.kind === 'Live' ||
latestData?.match.kind === 'Fixture'
? refreshInterval
: 0;
},
});

const fetcher =
(selected: TabName, getHeaderData: Props['getHeaderData']) =>
(url: string): Promise<CricketHeaderData> =>
getHeaderData(url)
.then(parseHeaderData(selected))
.then((result) => {
if (!result.ok) {
log('dotcom', result.error);
throw new Error();
} else {
return result.value;
}
})
.catch(() => {
log('dotcom', 'Failed to fetch match header json');
throw new Error();
});

const StatusLine = (props: { match: CricketMatch; edition: EditionId }) => (
<p
css={{
Expand Down
95 changes: 95 additions & 0 deletions dotcom-rendering/src/components/CricketMatchHeader/headerData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { ComponentProps } from 'react';
import { safeParse } from 'valibot';
import { type CricketMatch, parseCricketMatchV2 } from '../../cricketMatchV2';
import type { FECricketMatchHeader } from '../../frontend/feCricketMatchHeader';
import { feCricketMatchHeaderSchema } from '../../frontend/feCricketMatchHeader';
import { safeParseURL } from '../../lib/parse';
import { error, fromValibot, ok, type Result } from '../../lib/result';
import type { Tabs } from '../FootballMatchHeader/Tabs';

export type CricketHeaderData = {
tabs: ComponentProps<typeof Tabs>;
match: CricketMatch;
};

export const parse =
(selected: CricketHeaderData['tabs']['selected']) =>
(json: unknown): Result<string, CricketHeaderData> => {
const feData = fromValibot(safeParse(feCricketMatchHeaderSchema, json));

if (!feData.ok) {
return error('Failed to validate match header json');
}

const parsedMatch = parseCricketMatchV2(feData.value.cricketMatch);

if (!parsedMatch.ok) {
return error('Failed to parse the match from the header json');
}

const maybeTabs = createTabs(
selected,
feData.value,
parsedMatch.value.kind,
);

if (!maybeTabs.ok) {
return error(
`The match header data contained an invalid ${maybeTabs.error.kind} URL`,
);
}

return ok({
match: parsedMatch.value,
tabs: maybeTabs.value,
});
};

type MatchURLError = {
kind: 'live' | 'report';
};

const createTabs = (
selected: CricketHeaderData['tabs']['selected'],
feData: FECricketMatchHeader,
matchKind: CricketMatch['kind'],
): Result<MatchURLError, CricketHeaderData['tabs']> => {
const reportURL =
feData.reportURL !== undefined
? safeParseURL(feData.reportURL)
: undefined;
const liveURL =
feData.liveURL !== undefined ? safeParseURL(feData.liveURL) : undefined;
if (reportURL !== undefined && !reportURL.ok) {
return error({ kind: 'report' });
}

if (liveURL !== undefined && !liveURL.ok) {
return error({ kind: 'live' });
}

switch (selected) {
case 'info':
return ok({
matchKind,
sportKind: 'cricket',
selected,
reportURL: reportURL?.value,
liveURL: liveURL?.value,
});
case 'live':
return ok({
matchKind,
sportKind: 'cricket',
selected,
reportURL: reportURL?.value,
});
case 'report':
return ok({
matchKind,
sportKind: 'cricket',
selected,
liveURL: liveURL?.value,
});
}
};
Loading
Loading