shootismoke/webapp

View on GitHub
src/frontend/components/layout/city.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
/**
 * This file is part of Sh**t! I Smoke.
 *
 * Copyright (C) 2018-2021  Marcelo S. Coelho, Amaury M.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import {
    Api,
    capitalize,
    distanceToStation,
    FrequencyContext,
    getAQI,
    getSwearWord,
    primaryPollutant,
    round,
} from '@shootismoke/ui';
import { BoxButton } from '@shootismoke/ui/lib/components/BoxButton';
import c from 'classnames';
import Link from 'next/link';
import React, { useContext, useEffect, useState } from 'react';

import warning from '../../../../assets/images/icons/warning_red.svg';
import { t } from '../../localization';
import {
    City,
    getSeoTitle,
    logEvent,
    reverseGeocode,
    sentryException,
} from '../../util';
import {
    AboutSection,
    AdSection,
    BlogSection,
    Cigarettes,
    DownloadSection,
    FeaturedSection,
    Footer,
    H1,
    HeroLayout,
    Loading,
    Nav,
    PollutantSection,
    RankingSection,
    SadFace,
    SearchBar,
    Section,
    Seo,
} from '..';

/**
 * These are errors that we know are okay, so we don't log them on Sentry.
 */
function isKnownError(error: string): boolean {
    return (
        // [openaq] Cannot normalize, got 0 result
        error.includes('Cannot normalize, got 0 result') ||
        // Station aqicn|8287 does not have PM2.5 measurings right now.
        error.includes('does not have PM2.5 measurings right now')
    );
}

interface CityProps {
    city: City;
    cities: City[];
}

export default function CityTemplate(props: CityProps): React.ReactElement {
    const { frequency, setFrequency } = useContext(FrequencyContext);
    const { city, cities } = props;
    const [api, setApi] = useState<Api | undefined>(city.api);
    const [error, setError] = useState<Error>();
    const [reverseGeoName, setReverseGeoName] = useState(city.name);

    // Log telemetry each time we change city.
    useEffect(() => {
        logEvent('Page.City.View', {
            name: city.name,
            gps: JSON.stringify(city.gps),
            slug: city.slug,
        });
    }, [city]);

    // Number of cigarettes to display.
    const cigarettes = api
        ? round(
                api.shootismoke.dailyCigarettes *
                    (frequency === 'daily'
                        ? 1
                        : frequency === 'weekly'
                        ? 7
                        : 30)
          )
        : undefined;

    // Decide on a swear word. The effect says that the swear word only changes
    // when the cigarettes count changes.
    const [swearWord, setSwearWord] = useState<string | undefined>(
        cigarettes ? t(getSwearWord(cigarettes)) : undefined
    );
    useEffect(() => {
        if (cigarettes === undefined) {
            return;
        }

        setSwearWord(t(getSwearWord(cigarettes)));
    }, [cigarettes]);

    // Evertime we change city, reset, and fetch new values.
    useEffect(() => {
        setApi(undefined);
        setSwearWord(undefined);

        setError(undefined);
        setReverseGeoName(undefined);

        reverseGeocode(city.gps).then(setReverseGeoName).catch(sentryException);

        // This `api` file imports a bunch of stuff, so we run it lazily.
        import('@shootismoke/ui/lib/util/api')
            .then(({ raceApiPromise }) => {
                const sixHoursAgo = new Date();
                sixHoursAgo.setHours(sixHoursAgo.getHours() - 6);

                return raceApiPromise(city.gps, {
                    aqicn: {
                        token: process.env.NEXT_PUBLIC_AQICN_TOKEN as string,
                    },
                    openaq: {
                        dateFrom: sixHoursAgo,
                        // Limiting to only fetch pm25. Sometimes, when
                        // we search for all pollutants, the pm25 ones
                        // don't get returned within the result limits.
                        parameter: ['pm25'],
                    },
                });
            })
            .then(setApi)
            .catch(setError);
    }, [city]); // eslint-disable-line react-hooks/exhaustive-deps

    // Log errors.
    useEffect(() => {
        try {
            if (!error) {
                return;
            }

            // Error message is often like: `1. {error1} 2. {error2}`.
            const errorParts = error.message.split(' 2. ');
            if (!errorParts[0] || !errorParts[1]) {
                sentryException(error);
            }
            const error1 = errorParts[0].substring(3).trim(); // remove the leading "1."
            const error2 = errorParts[1].trim();

            !isKnownError(error1) && sentryException(new Error(error1));
            !isKnownError(error2) && sentryException(new Error(error2));
        } catch (err) {
            sentryException(err as Error);
        }
    }, [error]);

    const distance = api ? distanceToStation(city.gps, api.pm25) : undefined;
    const primaryPol = api && (primaryPollutant(api.results) || api.results[0]); // We fallback to first value. FIXME.
    const aqi = api && (getAQI(api.results) || api.results[0].value); // We fallback to first value. FIXME.

    return (
        <>
            <Seo
                description={
                    reverseGeoName
                        ? `Air pollution in ${city.name || reverseGeoName}. `
                        : undefined
                }
                pathname={city.slug ? `/city/${city.slug}` : '/city'}
                title={getSeoTitle(api?.shootismoke.dailyCigarettes, city.name)}
            />

            <Nav />

            <Section noPadding>
                <div className="px-6 md:px-24">
                    <SearchBar
                        cities={cities}
                        placeholder={
                            city.name
                                ? [city.name, city.adminName, city.country]
                                        .filter((x) => !!x)
                                        .join(', ')
                                : reverseGeoName || 'Search for any city'
                        }
                    />
                    <p className="mt-2 type-100 text-gray-600">
                        {distance !== undefined ? (
                            api?.shootismoke.isAccurate === false ? (
                                <Link href="/faq#station-so-far">
                                    <a className="text-red hover:underline">
                                        Air Quality Station: {distance}km away
                                        <img
                                            alt="warning"
                                            className="ml-1 inline"
                                            src={warning}
                                        />
                                    </a>
                                </Link>
                            ) : (
                                `Air Quality Station: ${distance}km away`
                            )
                        ) : (
                            '\b' // So that the <p> doesn't collapse.
                        )}
                    </p>
                </div>

                {cigarettes !== undefined && swearWord ? (
                    <>
                        <div className="mt-5 px-6 md:px-24">
                            <HeroLayout
                                cover={<Cigarettes cigarettes={cigarettes} />}
                                title={
                                    <H1>
                                        <>
                                            {swearWord}! You smoke{' '}
                                            <span className="text-orange">
                                                {cigarettes >= 100
                                                    ? Math.round(cigarettes)
                                                    : cigarettes}{' '}
                                                cigarette
                                                {cigarettes === 1 ? '' : 's'}
                                            </span>
                                        </>
                                    </H1>
                                }
                            />
                        </div>

                        <div className="pl-6 md:pl-0 md:ml-24 mt-4 overflow-auto flex">
                            {(['daily', 'weekly', 'monthly'] as const).map(
                                (f) => (
                                    <div
                                        className="mr-4 cursor-pointer"
                                        key={f}
                                    >
                                        <BoxButton
                                            onPress={(): void => {
                                                logEvent(
                                                    `City.FrequencyButton.${capitalize(
                                                        f
                                                    )}.Click`
                                                );
                                                setFrequency(f);
                                            }}
                                        >
                                            <p
                                                className={c(
                                                    'py-1 type-600 md:type-700',
                                                    f !== frequency &&
                                                        'text-gray-200'
                                                )}
                                            >
                                                {f}
                                            </p>
                                        </BoxButton>
                                    </div>
                                )
                            )}
                        </div>
                    </>
                ) : error ? (
                    <SadFace
                        className="mt-5 px-6 md:px-24"
                        message={
                            'The closest station currently does not have PM2.5 measurings.'
                        }
                    />
                ) : (
                    <Loading className="mt-5 px-6 md:px-24" />
                )}
            </Section>

            {primaryPol && aqi && (
                <PollutantSection aqi={aqi} pollutant={primaryPol.parameter} />
            )}

            <RankingSection cities={cities} currentCity={city} />
            <AdSection />
            <AboutSection />
            <FeaturedSection />
            <BlogSection />
            <DownloadSection />
            <Footer />
        </>
    );
}