Skip to content

Commit

Permalink
feat(web): handle historcal linking with basic navigation (#7397)
Browse files Browse the repository at this point in the history
* feat(web): handle historcal linking with basic navigation

* remove console log

* call last hour

* change nav in ranking panel test

* use builtin date range formatting

* PR feedback

* PR feedback

* make buttons a11y friends + remove last_hour

* onClick handler on button

* simplify navigation logic

* PR feedback
  • Loading branch information
tonypls authored Nov 20, 2024
1 parent 0c89e6a commit 30ea0ce
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 61 deletions.
10 changes: 7 additions & 3 deletions web/cypress/e2e/countrypanel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
describe('Country Panel', () => {
beforeEach(() => {
cy.interceptAPI('v8/state/hourly');
cy.interceptAPI('v8/state/last_hour');
cy.interceptAPI('v8/meta');
});

Expand Down Expand Up @@ -85,15 +84,20 @@ describe('Country Panel', () => {
});

it('asserts countryPanel contains no parser message when zone has no data', () => {
// Add all required API intercepts
cy.interceptAPI('v8/state/hourly');
cy.interceptAPI('v8/details/hourly/CN');
cy.visit('/zone/CN?lang=en-GB', {
cy.interceptAPI('v8/meta'); // Add this if needed

cy.visit('/zone/CN/24h?lang=en-GB', {
onBeforeLoad(win) {
delete win.navigator.__proto__.serviceWorker;
},
});
cy.waitForAPISuccess('v8/state/last_hour');

cy.waitForAPISuccess('v8/state/hourly');
cy.waitForAPISuccess('v8/details/hourly/CN');

cy.get('[data-test-id=no-parser-message]').should('exist');
});

Expand Down
2 changes: 0 additions & 2 deletions web/cypress/e2e/ranking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
describe('Ranking Panel', () => {
it('interacts with details', () => {
cy.interceptAPI('v8/meta');
cy.interceptAPI('v8/state/last_hour');
cy.interceptAPI('v8/state/hourly');
cy.interceptAPI('v8/details/hourly/DK-DK2');
cy.visit('/?lang=en-GB');
cy.get('[data-test-id=close-modal]').click();
cy.waitForAPISuccess(`v8/meta`);
cy.waitForAPISuccess(`v8/state/last_hour`);
cy.waitForAPISuccess(`v8/state/hourly`);
cy.get('[data-test-id=loading-overlay]').should('not.exist');

Expand Down
57 changes: 28 additions & 29 deletions web/src/api/getState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ import type { GridState, RouteParameters } from 'types';
import { TimeAverages } from 'utils/constants';
import { URL_TO_TIME_AVERAGE } from 'utils/state/atoms';

import { cacheBuster, getBasePath, QUERY_KEYS } from './helpers';

const getState = async (timeAverage: string): Promise<GridState> => {
const path: URL = new URL(`v8/state/${timeAverage}`, getBasePath());
path.searchParams.append('cacheKey', cacheBuster());

import { cacheBuster, getBasePath, isValidDate, QUERY_KEYS } from './helpers';

const getState = async (
timeAverage: TimeAverages,
targetDatetime?: string
): Promise<GridState> => {
const shouldQueryHistorical =
targetDatetime && isValidDate(targetDatetime) && timeAverage === TimeAverages.HOURLY;
const path: URL = new URL(
`v8/state/${timeAverage}${
shouldQueryHistorical ? `?targetDate=${targetDatetime}` : ''
}`,
getBasePath()
);

if (!targetDatetime) {
path.searchParams.append('cacheKey', cacheBuster());
}
const response = await fetch(path);

if (response.ok) {
const result = (await response.json()) as GridState;
return result;
Expand All @@ -22,32 +33,20 @@ const getState = async (timeAverage: string): Promise<GridState> => {
};

const useGetState = (): UseQueryResult<GridState> => {
const { urlTimeAverage } = useParams<RouteParameters>();
const { urlTimeAverage, urlDatetime } = useParams<RouteParameters>();
const timeAverage = urlTimeAverage
? URL_TO_TIME_AVERAGE[urlTimeAverage]
: TimeAverages.HOURLY;
const isHourly = urlTimeAverage ? timeAverage === TimeAverages.HOURLY : false;

// First fetch last hour only
const last_hour = useQuery<GridState>({
queryKey: [QUERY_KEYS.STATE, { aggregate: 'last_hour' }],
queryFn: async () => getState('last_hour'),
enabled: isHourly,
return useQuery<GridState>({
queryKey: [
QUERY_KEYS.STATE,
{
aggregate: timeAverage,
targetDatetime: urlDatetime,
},
],
queryFn: () => getState(timeAverage, urlDatetime),
});

const hourZeroWasSuccessful = Boolean(last_hour.isLoading === false && last_hour.data);

const shouldFetchFullState =
!isHourly || hourZeroWasSuccessful || last_hour.isError === true;

// Then fetch the rest of the data
const all_data = useQuery<GridState>({
queryKey: [QUERY_KEYS.STATE, { aggregate: timeAverage }],
queryFn: async () => getState(timeAverage),
enabled: shouldFetchFullState,
});

return (all_data.data || !isHourly ? all_data : last_hour) ?? {};
};

export default useGetState;
41 changes: 31 additions & 10 deletions web/src/api/getZone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@ import { RouteParameters } from 'types';
import { TimeAverages } from 'utils/constants';
import { URL_TO_TIME_AVERAGE } from 'utils/state/atoms';

import { cacheBuster, getBasePath, getHeaders, QUERY_KEYS } from './helpers';
import { cacheBuster, getBasePath, getHeaders, isValidDate, QUERY_KEYS } from './helpers';

const getZone = async (
timeAverage: TimeAverages,
zoneId?: string
zoneId: string,
targetDatetime?: string
): Promise<ZoneDetails> => {
invariant(zoneId, 'Zone ID is required');
const path: URL = new URL(`v8/details/${timeAverage}/${zoneId}`, getBasePath());
path.searchParams.append('cacheKey', cacheBuster());

const shouldQueryHistorical =
targetDatetime && isValidDate(targetDatetime) && timeAverage === TimeAverages.HOURLY;

const path: URL = new URL(
`v8/details/${timeAverage}/${zoneId}${
shouldQueryHistorical ? `?targetDate=${targetDatetime}` : ''
}`,
getBasePath()
);
if (!targetDatetime) {
path.searchParams.append('cacheKey', cacheBuster());
}
const requestOptions: RequestInit = {
method: 'GET',
headers: await getHeaders(path),
Expand All @@ -35,17 +46,27 @@ const getZone = async (
throw new Error(await response.text());
};

// TODO: The frontend (graphs) expects that the datetimes in state are the same as in zone
// should we add a check for this?
const useGetZone = (): UseQueryResult<ZoneDetails> => {
const { zoneId } = useParams<RouteParameters>();
const { urlTimeAverage } = useParams<RouteParameters>();
const { zoneId, urlTimeAverage, urlDatetime } = useParams<RouteParameters>();

const timeAverage = urlTimeAverage
? URL_TO_TIME_AVERAGE[urlTimeAverage]
: TimeAverages.HOURLY;
return useQuery<ZoneDetails>({
queryKey: [QUERY_KEYS.ZONE, { zone: zoneId, aggregate: timeAverage }],
queryFn: async () => getZone(timeAverage, zoneId),
queryKey: [
QUERY_KEYS.ZONE,
{
zone: zoneId,
aggregate: urlTimeAverage,
targetDatetime: urlDatetime,
},
],
queryFn: async () => {
if (!zoneId) {
throw new Error('Zone ID is required');
}
return getZone(timeAverage, zoneId, urlDatetime);
},
});
};

Expand Down
11 changes: 11 additions & 0 deletions web/src/api/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,14 @@ export const QUERY_KEYS = {
ZONE: 'zone',
META: 'meta',
};
export function isValidDate(dateString: string) {
if (Number.isNaN(Date.parse(dateString))) {
throw new TypeError('Invalid date string: ' + dateString);
}
const oldestDatetimeToSupport = new Date('2017-01-01T00:00:00Z');
const parsedDate = new Date(dateString);
if (parsedDate > oldestDatetimeToSupport) {
return true;
}
return false;
}
3 changes: 1 addition & 2 deletions web/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export function Button({
const As = getComponentType(renderAsLink, asDiv);
const componentType = renderAsLink ? undefined : 'button';
const isIconOnly = !children && Boolean(icon);

return (
<div
className={twMerge(
Expand All @@ -49,7 +48,7 @@ export function Button({
>
<As
className={twMerge(
`flex h-full w-full flex-row items-center justify-center rounded-full text-sm font-semibold
`flex h-full w-full select-none flex-row items-center justify-center rounded-full text-sm font-semibold
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-green disabled:text-neutral-400
disabled:hover:bg-inherit disabled:dark:text-gray-500 ${getSize(
size,
Expand Down
15 changes: 12 additions & 3 deletions web/src/components/Time.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom';
import { RouteParameters } from 'types';
import { TimeAverages } from 'utils/constants';
import { formatDate } from 'utils/formatting';
import { formatDate, formatDateRange } from 'utils/formatting';
import { getZoneTimezone } from 'utils/helpers';

export function FormattedTime({
Expand All @@ -10,19 +10,28 @@ export function FormattedTime({
timeAverage,
className,
zoneId,
endDatetime,
}: {
datetime: Date;
language: string;
timeAverage: TimeAverages;
timeAverage?: TimeAverages;
className?: string;
zoneId?: string;
endDatetime?: Date;
}) {
const { zoneId: pathZoneId } = useParams<RouteParameters>();
const timeZoneZoneId = zoneId || pathZoneId;
const timezone = getZoneTimezone(timeZoneZoneId);
if (timeAverage) {
return (
<time dateTime={datetime.toISOString()} className={className}>
{formatDate(datetime, language, timeAverage, timezone)}
</time>
);
}
return (
<time dateTime={datetime.toISOString()} className={className}>
{formatDate(datetime, language, timeAverage, timezone)}
{endDatetime && formatDateRange(datetime, endDatetime, language, timezone)}
</time>
);
}
102 changes: 102 additions & 0 deletions web/src/features/time/HistoricalTimeHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import useGetState from 'api/getState';
import Badge from 'components/Badge';
import { Button } from 'components/Button';
import { FormattedTime } from 'components/Time';
import { useAtomValue } from 'jotai';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { RouteParameters } from 'types';
import { useNavigateWithParameters } from 'utils/helpers';
import { endDatetimeAtom, isHourlyAtom, startDatetimeAtom } from 'utils/state/atoms';

const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;

export default function HistoricalTimeHeader() {
const { i18n } = useTranslation();
const startDatetime = useAtomValue(startDatetimeAtom);
const endDatetime = useAtomValue(endDatetimeAtom);
const isHourly = useAtomValue(isHourlyAtom);
const { isLoading } = useGetState();
const { urlDatetime } = useParams<RouteParameters>();
const navigate = useNavigateWithParameters();

function handleRightClick() {
if (!endDatetime || !urlDatetime) {
return;
}
const currentEndDatetime = new Date(endDatetime);
const newDate = new Date(currentEndDatetime.getTime() + TWENTY_FOUR_HOURS);

const twentyFourHoursAgo = new Date(Date.now() - TWENTY_FOUR_HOURS);
if (newDate >= twentyFourHoursAgo) {
navigate({ datetime: '' });
return;
}
navigate({ datetime: newDate.toISOString() });
}

function handleLeftClick() {
if (!endDatetime) {
return;
}
const currentEndDatetime = new Date(endDatetime);
const newDate = new Date(currentEndDatetime.getTime() - TWENTY_FOUR_HOURS);
navigate({ datetime: newDate.toISOString() });
}

return (
<div className="flex min-h-6 flex-row items-center justify-between">
{!isLoading && startDatetime && endDatetime && (
<Badge
pillText={
<FormattedTime
datetime={startDatetime}
language={i18n.languages[0]}
endDatetime={endDatetime}
/>
}
type="success"
/>
)}
<div className="flex flex-row items-center gap-2">
<Button
backgroundClasses="bg-transparent"
onClick={handleLeftClick}
size="sm"
type="tertiary"
isDisabled={!isHourly}
icon={
<ChevronLeft
className={twMerge('text-brand-green', !isHourly && 'opacity-50')}
/>
}
/>
<Button
backgroundClasses="bg-transparent"
size="sm"
onClick={handleRightClick}
type="tertiary"
isDisabled={!isHourly || !urlDatetime}
icon={
<ChevronRight
className={twMerge(
'text-brand-green',
(!urlDatetime || !isHourly) && 'opacity-50'
)}
/>
}
/>
<Button
size="sm"
type="secondary"
onClick={() => navigate({ datetime: '' })}
isDisabled={!urlDatetime}
>
Latest
</Button>
</div>
</div>
);
}
Loading

0 comments on commit 30ea0ce

Please sign in to comment.