Weather app in React, Redux, Typescript, and Tailwind

keno_sej

Kenan Sejmenović

Posted on August 2, 2020

Weather app in React, Redux, Typescript, and Tailwind

Hello reader πŸ‘‹πŸ‘‹,

In this article, you will learn how to make basic weather app in React, Redux, and Typescript.

The React part is written in Typescript.

The Redux part is written in plain Javascript for the sake of simplicity.

This article is meant for beginners in React. I have about a year's experience in Vue and Vuex before I started learning React and Redux. It's best suited for those readers.

Let me show you the app, then we will mix reverse engineering and going from step one to the final app.

Hope you liked it! If you don't have time to read, here is the source code. πŸ‘ˆ

Let's start

Requirements: node.js and npm.


Step 1

Install packages

Let's first execute commands, then I will explain what each command does.

Open your terminal and execute commands:

npx create-react-app weather --template typescript
Enter fullscreen mode Exit fullscreen mode
cd weather
Enter fullscreen mode Exit fullscreen mode
npm install react-icons react-redux react-router-dom redux redux-thunk tailwindcss postcss-cli autoprefixer @fullhuman/postcss-purgecss @types/react-redux @types/react-router-dom
Enter fullscreen mode Exit fullscreen mode

Take a look at why React does not put dependencies in devDependendencies.

The first command builds React template in Typescript. We named our app "weather".

The second command moves us into the application directory.

The third command installs packages:

​ react-icons - for fancy icons

​ react-redux - for connecting Redux with React

​ react-router-dom - for enabling many routes and SPA navigation (SPA - Single Page Application)

​ redux - for state management

​ redux-thunk - for enabling asynchronous behavior in redux

​ tailwindcss - CSS framework for easier styling

​ postcss-cli - for enabling minifying app for production (CSS file gzipped from ~140kb to ~3kb... WORTH IT 🧐)

​ autoprefixer - for parsing CSS and adding vendor prefixes to CSS rules

​ @fullhuman/postcss-purgecss - PostCSS plugin for PurgeCSS

​ @types/react-redux - type definitions for react-redux (for Typescript)

​ @types/react-router-dom - type definitions for React Router (for Typescript)

Let's start application:

npm start
Enter fullscreen mode Exit fullscreen mode

Step 2

Remove auto-generated code

Let's remove minimal code that interferes with our goals, for now.

Go into ./src/App.tsx and remove code inside return statement to look like:

return <></>;
Enter fullscreen mode Exit fullscreen mode

At the top you can see:

import logo from "./logo.svg";
import "./App.css";
Enter fullscreen mode Exit fullscreen mode

Remove both imports and delete ./src/App.css.

If you see a white screen on your browser, you are good to go.

For now, it's good. Delete other useless code if you want, but to keep this post shorter, I will cut it here.


Step 3

Building structure

We need to make five new directories inside ./src.

Inside ./src make:

  • actions

  • assets

  • components

  • pages

  • reducers

Explanation:

  • actions - for storing redux actions and action types
  • assets - for static content, like images
  • components - it's always a good thing to strive for the Single Responsibility Principle. In a bigger project, you will be able to use the same component multiple times and save time for everyone
  • pages - a place of clean code and separate concerns where you connect routes to components
  • reducers - place where dispatched redux actions changes the state of the application

Step 4

Enable Tailwind

Let's add Tailwind to the application.

Open ./src/index.tsx and add:

import "./tailwind.output.css";
Enter fullscreen mode Exit fullscreen mode

Also, add ./tailwind.config.js, so we learn how to add custom properties to Tailwind.

./tailwind.config.js

module.exports = {
  theme: {
    extend: {
      width: {
        "410px": "410px",
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Before npm start and npm run build we want to build Tailwind also.

To solve this problem, in "scripts" tag in package.json add:

"build:tailwind": "tailwind build src/tailwind.css -o src/tailwind.output.css",
"prestart": "npm run build:tailwind",
"prebuild": "npm run build:tailwind"
Enter fullscreen mode Exit fullscreen mode

Adding "pre" before the start and build, will run the desired command before every npm start and npm run build.

As you can see, there is src/tailwind.css, which is not created yet. So, let's do it.

./src/tailwind.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Stop watching changes in code by npm by hitting Ctrl + C on Windows in the terminal.

Again, run npm start to compile code. You should now see something like in console:

npm run build:tailwind

And tailwind.output.css should appear in ./src.


Step 5

Prepare Redux

In ./src/reducers make:

./src/reducers/ajaxReducer.js:

const initialState = {
    weather: {},
  };

  export default function (state = initialState, action) {
      switch (action.type) {
          default:
              return state;
      }
  }
Enter fullscreen mode Exit fullscreen mode

We will fetch data from OpenWeatherMap, so we need a place to store data.

Data will be store in the weather, in the state.

For now, let's write the boilerplate code.

./src/reducers/index.js:

import { combineReducers } from "redux";
import ajaxReducer from "./ajaxReducer";

export default combineReducers({
  weatherReducer: ajaxReducer,
});
Enter fullscreen mode Exit fullscreen mode

At index.js combine all the reducers. We have only one - ajaxReducer in this project, but it won't be always the case.

At a large project, having index.js - a central place of Redux reducers is a good thing, "clean code".

Time for action.. actions!

Let's make types.js where we store all types of Redux actions. It's like ./src/reducers/index.js for actions.

In this simple project, we will only have one action.

./src/actions/types.js

export const FETCH_WEATHER = "FETCH_WEATHER";
Enter fullscreen mode Exit fullscreen mode

And, let's make one and only ajax request/redux action. Before that, you need to go to the OpenWeatherMap and make a token.

A token is a requirement for using OpenWeatherMap, that is generous enough to give us a very high number of API calls for free.

./src/actions/ajaxActions.js

import { FETCH_WEATHER } from "./types";

export const fetchWeather = () => async (dispatch) => {
  const ids = {
    Munich: 2867714,
    London: 2643743,
    California: 4350049,
  };

  const fetches = await Promise.all(
    Object.values(ids).map((e) =>
      fetch(
        `https://api.openweathermap.org/data/2.5/forecast?id=${e}&appid=` // here you put your token key
      ).then((e) => e.json())
    )
  );

  dispatch({
    type: FETCH_WEATHER,
    payload: {
      // iterating through object does not guarantee order, so I chose manually
      Munich: fetches[0],
      London: fetches[1],
      California: fetches[2],
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

I chose those cities because I like them. You can pick the cities which you like. Here you can find IDs.

Explanation of ./src/actions/ajaxActions.js:

  1. Import type, so we can connect type with defined action
  2. Make an object of city names and IDs
  3. Store fetched and parsed JSON into constant fetches. Use Promise.all() for fetching data of cities concurrently. URL needs city ID and also Promise.all() expects argument of an array type. Do it by making an array from the object of cities and their ID with Object.values(). Iterate through it with a high-order function map, which returns the array. Fetch does not parse JSON, and it's asynchronous, so first wait for fetching data. Then "unpack" (parse) it by another asynchronous method: JSON. You could use await keyword again, but I prefer then, it seems like beautiful syntax.
  4. In the argument, you can see that we grabbed dispatch, so we can later dispatch an action to the store. If it isn't understandable, read about Javascript closures.
  5. In the end, we call dispatch and pass an object with two keys: type and payload. In type, we link type from ./src/actions/types.js, and in payload, we store data returned from API. There are a lot of ways to not duplicate yourself in this code, but I chose this way for simplicity's sake.

We left ajaxReducer.js unfinished. It's time to complete it.

./src/reducers/ajaxReducer.js

import { FETCH_WEATHER } from "../actions/types";

const initialState = {
  weather: {},
};

export default function (state = initialState, action) {
  switch (action.type) {
    case FETCH_WEATHER:
      return {
        ...state,
        weather: action.payload,
      };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Redux does not allow us to change just one bit of a state from reducers. First, destructure the current state. Immediately after, overwrite weather key with action payload from ./src/actions/ajaxActions.js.


Step 6

Connect app to redux

Let's first make the main file of Redux. If you worked before with Vuex, you will recognize a pattern here. Vuex and Redux are very similar.

Both have the same purpose, but Vuex is a little easier to understand. Let's name the main Redux file.

./src/store.js

import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import rootReducer from "./reducers";

const initialState = {};

const middleware = [thunk];

const store = createStore(
  rootReducer,
  initialState,
  applyMiddleware(...middleware)
);

export default store;
Enter fullscreen mode Exit fullscreen mode

Make it super clean. The code is self-explaining. Clean boilerplate for bigger projects.

In ./src/App.tsx it's time to make some changes.

./src/App.tsx

import React from "react";
import { Provider } from "react-redux";
import store from "./store";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";

import Home from "./pages/Home";

function App() {
  return (
    <Provider store={store}>
      <Router>
        <Switch>
          <Route path="/" component={Home} />
        </Switch>
      </Router>
    </Provider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

To make React application work with Redux, we need to wrap the app in , which receives ./src/store.js. It's possible to have multiple stores. Saw it before, but not a big fan //yet 🀣.

You saw a couple of errors in your terminal if you saved your code, I'm sure. It's time to make a first page - Home.


Step 7

Naming assets

For background of cards on home page, I use gifs, so here are names (put whatever gifs you like):

./src/assets/clear.gif

./src/assets/clouds.gif

./src/assets/drizzle.gif

./src/assets/fog.gif

./src/assets/rain.gif

./src/assets/snow.gif

./src/assets/thunderstorm.gif

For Home page eight pictures are used. Four for phones, four for desktops.

For phones:

​ ./src/assets/p_bg1.jpg

​ ...

​ ./src/assets/p_bg4.jpg

For desktops:

​ ./src/assets/d_bg1.jpg

​ ...

​ ./src/assets/d_bg4.jpg


Step 8

Home and its components

./src/pages/Home.tsx

import React, { Component } from "react";

import Card from "../components/home/Card";
import { connect } from "react-redux";
import { fetchWeather } from "../actions/ajaxActions";

interface FormProps {
  fetchWeather: Function;
  weather: Record<string, any>;
}

interface FormState {
  random: number;
  imageSource: string;
}

class Home extends Component<FormProps, FormState> {
  constructor(props: any) {
    super(props);

    const randomInt = (min: number, max: number) =>
      Math.floor(Math.random() * (max - min)) + min; // generate random integer

    this.state = {
      random: randomInt(1, 5), // randomly select background, whose names ends with 1 | 2 | 3 | 4
      imageSource: "",
    };
  }

  // select randomly/change background on click
  setBg = (type: "default" | "click"): void => {
    if (type === "default") {
      this.setState({
        imageSource: require(`../assets/${
          window.innerWidth < 768 ? "p" : "d"
        }_bg${this.state.random}.jpg`),
      });
    } else if (type === "click") {
      // increase random num, then call recursive callback
      if (this.state.random === 4) {
        return this.setState(
          {
            random: 1,
          },
          () => this.setBg("default")
        );
      }

      return this.setState(
        {
          random: this.state.random + 1,
        },
        () => this.setBg("default")
      );
    }
  };

  componentDidMount() {
    this.props.fetchWeather();
    this.setBg("default");
    window.addEventListener("resize", () => this.setBg("default"));
  }

  render() {
    return (
      <div
        className="h-screen w-screen bg-cover bg-center"
        style={{
          backgroundImage: `url(${this.state.imageSource})`,
        }}
        onClick={() => this.setBg("click")}
      >
        <div
          className="flex flex-col justify-center items-center w-screen"
          style={{ height: "95%" }}
        >
          {Object.keys(this.props.weather).map((e, i) => {
            return <Card city={e} key={i} weather={this.props.weather[e]} />;
          })}
        </div>
      </div>
    );
  }
}

const mstp = (state: { weatherReducer: { weather: {} } }) => ({
  weather: state.weatherReducer.weather,
});

export default connect(mstp, { fetchWeather })(Home);
Enter fullscreen mode Exit fullscreen mode

Take advantage of Typescript, by predefining types of component props and state.

Define the component as a class component. The same thing can be done with React Hooks.

The thing to remember at expression setBg is that setState won't immediately set state, so take advantage of its second argument. It receives callback which will execute immediately after the state is updated. And then its time for the recursive call, to change background photo.

  • The single argument of an arrow function you could write without parentheses. For clarity purposes, let's keep 'em

./src/components/home/Card.tsx

Name your components with a capital letter!

import LeftComponent from "./LeftComponent";
import { Link } from "react-router-dom";
import React from "react";
import { RiMapPinLine } from "react-icons/ri";
import RightComponent from "./RightComponent";
import Tomorrow from "./Tomorrow";
import { determineGif } from "../Utils";

interface FormProps {
  city: string;
  weather: any;
}

function Card(props: FormProps) {
  // find min. and max. temperatures from all timestamps from today
  const findMinAndMaxTemps = (list: any[]): [number, number] => {
    const d = new Date();

    const today = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
    let min: number[] = [],
      max: number[] = [];

    list.forEach((e) => {
      if (`${e.dt_txt[8]}${e.dt_txt[9]}` === today.toString()) {
        min.push(e.main.temp_min);
        max.push(e.main.temp_max);
      }
    });

    return [
      Math.round(Math.min(...min) - 273.15),
      Math.round(Math.max(...max) - 273.15),
    ];
  };

  let temperature = 0,
    minTemperature = 0,
    maxTemperature = 0,
    stateOfWeather = "",
    feelsLike = 0,
    speed = 0,
    deg = 0,
    idOfWeather = 0,
    day = true,
    list = [];

  if (props.weather?.list) {
    temperature = Math.round(props.weather.list[0].main.temp - 273.15);
    [minTemperature, maxTemperature] = findMinAndMaxTemps(props.weather.list);
    stateOfWeather = props.weather.list[0].weather[0].main;
    feelsLike = Math.round(props.weather.list[0].main.temp - 273.15);
    speed = props.weather.list[0].wind.speed;
    deg = props.weather.list[0].wind.deg;
    idOfWeather = props.weather.list[0].weather[0].id;
    day = props.weather.list[0].sys.pod === "d";
    list = props.weather.list;
  }

  const [classes, url] = determineGif(idOfWeather);

  return (
    <Link to={`/${props.city}`} className="h-40 w-full sm:w-410px">
      <div className="flex h-40 w-full sm:w-410px">
        <div
          className={`text-white m-2 rounded-lg flex-grow bg-left-bottom ${classes}`}
          style={{
            backgroundImage: `url(${url})`,
          }}
        >
          <div className="flex w-full h-full divide-x divide-gray-400 ">
            <div className="w-9/12">
              <div
                className="mt-2 ml-2 p-2 rounded-lg inline-block text-xs"
                style={{
                  boxShadow: "0 0 15px 1px rgba(0, 0, 0, 0.75)",
                  backdropFilter: "blur(2px)",
                }}
              >
                <div className="flex items-center">
                  <RiMapPinLine />
                  <div className="ml-2">{props.city}</div>
                </div>
              </div>
              <div className="w-full flex justify-around items-center">
                <LeftComponent
                  stateOfWeather={stateOfWeather}
                  idOfWeather={idOfWeather}
                  day={day}
                />
                <div className="flex flex-col text-center">
                  <div className="text-5xl">{temperature}Β°</div>
                  <div className="text-lg">
                    {minTemperature}/{maxTemperature}Β°
                  </div>
                </div>
                <RightComponent speed={speed} deg={deg} feelsLike={feelsLike} />
              </div>
            </div>
            <Tomorrow idOfWeather={idOfWeather} day={day} list={list} />
          </div>
        </div>
      </div>
    </Link>
  );
}

export default Card;
Enter fullscreen mode Exit fullscreen mode

If you are curious about determineGif, continue reading, we are almost there!

Take a look at an API response structure, so you can understand variable pairing.

The API response is in Kelvin, so to get Celsius you need to subtract 273.15.

You could do the same thing by passing units=metric at request URL, but its great to meet Javascript floating point number precision.

Remove Math.round() and time will tell you about it 🀣.

As you can see, we get into Tailwind. Tailwind is nice, I would say 'micro' CSS framework, that almost doesn't let you write raw CSS. I don't like it like I do Vuetify, but if you need to manage style at a low and small level, it's great! The thing that I most like about it, it's great documentation.

This component could be separated into smaller parts. But to be time-friendly, I kept it relatively "big".

There are 3 more components, so let's explore 🧐.

./src/components/home/LeftComponent.tsx

import React from "react";
import { determineIcon } from "../Utils";

interface FormProps {
  stateOfWeather: string;
  idOfWeather: number;
  day: boolean;
}

function LeftComponent(props: FormProps) {
  return (
    <div className="flex flex-col text-center">
      {determineIcon(props.idOfWeather, props.day, "h-16 w-16")}
      <div>{props.stateOfWeather}</div>
    </div>
  );
}

export default LeftComponent;
Enter fullscreen mode Exit fullscreen mode

./src/components/home/RightComponent.tsx

import React from "react";

interface FormProps {
  feelsLike: number;
  deg: number;
  speed: number;
}

function RightComponent(props: FormProps) {
  const determineLevel = (temp: number): string[] => {
    if (temp < 10 || temp > 29) {
      return ["Bad", "bg-red-600"];
    }

    if ((temp > 9 && temp < 18) || (temp > 22 && temp < 30)) {
      return ["ok", "bg-yellow-600"];
    }

    if (temp > 17 && temp < 23) {
      return ["Good", "bg-green-600"];
    }

    return [];
  };

  const determineSide = (deg: number): string | undefined => {
    if (deg < 30) return "N";

    if (deg < 60) return "NE";

    if (deg < 120) return "E";

    if (deg < 150) return "ES";

    if (deg < 210) return "S";

    if (deg < 240) return "SW";

    if (deg < 300) return "W";

    if (deg < 330) return "NW";

    if (deg < 360) return "N";
  };

  const feelsLikeProperties = determineLevel(props.feelsLike);

  return (
    <div className="self-end text-center">
      <div
        className={`${feelsLikeProperties[1]} rounded-lg text-xs sm:text-sm p-1`}
      >
        {props.feelsLike} {feelsLikeProperties[0]}
      </div>
      <div className="mt-1 text-xs md:text-sm">
        {determineSide(props.deg)} {Math.round(props.speed * 3.6)} km/h
      </div>
    </div>
  );
}

export default RightComponent;
Enter fullscreen mode Exit fullscreen mode

determineLevel return could be better, but let's keep it simple.

Wind response is in m/s, so to convert it to km/h multiply by 3.6.

determineSide is there for determining if its north, east...

I have a challenge for you - after you make this application, try to make a feature to toggle wind speed between m/s, km/h, and km/s.

./src/components/home/Tomorrow.tsx

import React from "react";
import { RiArrowRightSLine } from "react-icons/ri";
import { determineIcon } from "../Utils";

interface FormProps {
  idOfWeather: number;
  day: boolean;
  list: [];
}

function Tomorrow(props: FormProps) {
  const determineNextDayAbb = (): string => {
    const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

    let date = new Date();
    let index: number;

    if (date.getDay() === 6) {
      index = 0;
    } else {
      index = date.getDay() + 1;
    }

    return weekdays[index];
  };

  const crawlNextDayTemps = (list: any[]): [number, number] | void => {
    const d = new Date();
    d.setDate(d.getDate() + 1); // tomorrow

    const tomorrow = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();

    let min: number[] = [],
      max: number[] = [];

    list.forEach((e) => {
      if (`${e["dt_txt"][8]}${e["dt_txt"][9]}` === tomorrow.toString()) {
        min.push(e.main.temp_min);
        max.push(e.main.temp_max);
      }
    });

    return [
      Math.round(Math.min(...min) - 273.15),
      Math.round(Math.max(...max) - 273.15),
    ];
  };

  const nextDayTemps = crawlNextDayTemps(props.list);

  return (
    <div className="w-3/12">
      <div className="flex justify-between p-2">
        <div className="text-xs">{determineNextDayAbb()}</div>
        <div className="text-xs flex items-center">
          <div>More</div>
          <RiArrowRightSLine />
        </div>
      </div>
      <div className="flex flex-col text-center">
        <div className="w-full">
          {determineIcon(props.idOfWeather, props.day, "h-16 w-16 mx-auto")}
        </div>
        <div className="text-lg">
          {Array.isArray(nextDayTemps) ? nextDayTemps[0] : "?"}/
          {Array.isArray(nextDayTemps) ? nextDayTemps[1] : "?"}Β°
        </div>
      </div>
    </div>
  );
}

export default Tomorrow;
Enter fullscreen mode Exit fullscreen mode

Expression names are self-explaining. The classical example of a functional component.


Step 9

City and its components

It's a pretty long article. A lot longer than I expected to πŸ˜….
Let's first add the city route to React.

./src/App.tsx

Before

<Route path="/" component={Home} />
Enter fullscreen mode Exit fullscreen mode

add:

<Route path="/:city" component={City} />
Enter fullscreen mode Exit fullscreen mode

Add the "City" route before the "Home" route, or take advantage of exact prop.

At the top of ./src/App.tsx add:

import City from "./pages/City";
Enter fullscreen mode Exit fullscreen mode

./src/pages/City.tsx

import React, { Component } from "react";

import Desktop from "../components/city/Desktop";
import { connect } from "react-redux";
import { fetchWeather } from "../actions/ajaxActions";

// match.params.city is URL (react-router) variable
interface FormProps {
  fetchWeather: Function;
  match: {
    params: {
      city: string;
    };
  };
  weather: Record<string, any>;
}

interface FormState {
  imageSrc: string;
  random: number;
}

class City extends Component<FormProps, FormState> {
  constructor(props: any) {
    super(props);

    if (
      this.props.match.params.city !== "Munich" &&
      this.props.match.params.city !== "London" &&
      this.props.match.params.city !== "California"
    ) {
      window.location.replace("/404");
      return;
    }

    if (!Object.keys(this.props.weather).length) {
      // fetch from api, if city is accessed directly
      this.props.fetchWeather();
    }

    const randomInt = (min: number, max: number) =>
      Math.floor(Math.random() * (max - min)) + min;

    this.state = {
      imageSrc: "",
      random: randomInt(1, 3), // choose random photo from 2 available photos
    };
  }

  updateDimensions = () => {
    // change background photo for phone/desktop
    this.setState({
      imageSrc: require(`../assets/${
        window.innerWidth < 768 ? "p" : "d"
      }_${this.props.match.params.city.toLowerCase()}${this.state.random}.jpg`),
    });
  };

  componentDidMount() {
    this.updateDimensions();
    window.addEventListener("resize", this.updateDimensions);
  }

  render() {
    return (
      <div
        className="h-screen w-screen bg-cover bg-center"
        style={{
          backgroundImage: `url(${this.state.imageSrc})`,
        }}
      >
        <Desktop
          city={this.props.match.params.city}
          info={this.props.weather[this.props.match.params.city]}
        />
      </div>
    );
  }
}

const mstp = (state: { weatherReducer: { weather: {} } }) => ({
  weather: state.weatherReducer.weather,
});

export default connect(mstp, { fetchWeather })(City);
Enter fullscreen mode Exit fullscreen mode

As you can see, if the URL is not these 3 cities, we redirect the user to the 404 pages. Challenge here for you is to make a good-looking 404 page.

The same pattern for changing background photo is used here.

In case the user enters URL directly, the application fetches data from API if there's no data in the state.

Here is the elephant of the code πŸ˜…

./src/components/city/Desktop.tsx

import React, { useState } from "react";
import { WiHumidity, WiStrongWind } from "react-icons/wi";

import { GiCrossedAirFlows } from "react-icons/gi";
import { MdVisibility } from "react-icons/md";
import { determineIcon } from "../Utils";

interface FormProps {
  city: string;
  info: any;
}

function Desktop(props: FormProps) {
  const [day, setDay] = useState(0);
  const [hour, setHour] = useState(0);

  const blurredChip = {
    boxShadow: "0 3px 5px rgba(0, 0, 0, 0.3)",
    backdropFilter: "blur(2px)",
  };

  const determineNext5Days = (): string[] => {
    const days = [
      "Sunday",
      "Monday",
      "Tuesday",
      "Wednesday",
      "Thursday",
      "Friday",
      "Saturday",
    ];

    let next5Days = [];

    for (let i = 0; i < 4; i++) {
      const d = new Date();
      d.setDate(d.getDate() + i);

      next5Days.push(days[d.getDay()]);
    }

    return next5Days;
  };

  interface Simplified {
    time: string;
    temp: number;
    feelsLike: number;
    weatherID: number;
    weatherState: string;
    day: boolean;
    humidity: number;
    pressure: number;
    windSpeed: number;
    visibility: number;
  }

  // pluck relevant info of todays timestamps
  const determineTimestamps = (day: number, list: any[]): any[] => {
    const d = new Date();
    d.setDate(d.getDate() + day);

    const timestamps: Simplified[] = [];

    for (const e of list) {
      if (parseInt(`${e["dt_txt"][8]}${e["dt_txt"][9]}`) === d.getDate()) {
        timestamps.push({
          time: e.dt_txt.slice(11, 16),
          temp: Math.round(e.main.temp - 273.15),
          feelsLike: Math.round(e.main.feels_like - 273.15),
          weatherID: e.weather[0].id,
          weatherState: e.weather[0].main,
          day: e.sys.pod === "d",
          humidity: e.main.humidity,
          pressure: e.main.pressure,
          windSpeed: Math.round(e.wind.speed * 3.6),
          visibility: Math.round(e.visibility / 100),
        });
      }
    }

    return timestamps;
  };

  // rather return the last timestamps than earlier ones (e.g. 21:00 > 03:00)
  const checkTerrain = (squares: number, tss: Simplified[]) => {
    let cut: any[] = [];

    const numberOfNeededRemoval = tss.length - squares;

    if (numberOfNeededRemoval < 0) return tss;

    for (let i = numberOfNeededRemoval; i < tss.length; i++) {
      cut.push(tss[i]);
    }

    return cut;
  };

  const adaptToWidth = (tss: Simplified[]) => {
    // show minimum four squares of timestamps to max 8
    if (tss.length < 5) return tss;

    if (window.innerWidth < 950) {
      return checkTerrain(4, tss);
    } else if (window.innerWidth < 1150) {
      return checkTerrain(5, tss);
    } else if (window.innerWidth < 1250) {
      return checkTerrain(6, tss);
    } else if (window.innerWidth < 1350) {
      return checkTerrain(7, tss);
    }

    return checkTerrain(8, tss);
  };

  // until info from api is fetched
  const timestamps = props.info?.list
    ? adaptToWidth(determineTimestamps(day, props.info?.list))
    : [];

  if (!timestamps.length) {
    return <></>;
  }

  // after fetch
  return (
    <>
      <div className="w-screen flex justify-between" style={{ height: "65%" }}>
        <div className="text-white pt-8 pl-8">
          <div className="text-6xl">
            {determineIcon(timestamps[hour].weatherID, timestamps[hour].day)}
          </div>
          <div className="text-4xl my-1 sm:my-0">
            {timestamps[hour].weatherState}
          </div>
          <div className="text-xl my-1 sm:my-0">{props.city}</div>
          <div className="text-5xl font-bold">{timestamps[hour].temp}Β°C</div>
        </div>
        <div className="mt-20 mr-4 md:mr-20">
          <div className="flex">
            <div className="text-gray-200 pr-1">
              <WiHumidity className="text-3xl" />
            </div>
            <div>
              <div className="text-gray-200 text-sm sm:base">Humidity</div>
              <div className="text-white text-2xl sm:text-3xl font-bold">
                {timestamps[hour].humidity}%
              </div>
            </div>
          </div>

          <div className="flex my-4">
            <div className="text-gray-200 pr-1">
              <GiCrossedAirFlows className="text-2xl" />
            </div>
            <div>
              <div className="text-gray-200 text-sm sm:base">Air Pressure</div>
              <div className="text-white text-2xl sm:text-3xl font-bold">
                {timestamps[hour].pressure} hPa
              </div>
            </div>
          </div>

          <div className="flex my-4">
            <div className="text-gray-200 pr-1">
              <WiStrongWind className="text-2xl" />
            </div>
            <div>
              <div className="text-gray-200 text-sm sm:base">Wind speed</div>
              <div className="text-white text-2xl sm:text-3xl font-bold">
                {timestamps[hour].windSpeed} km/h
              </div>
            </div>
          </div>

          <div className="flex my-4">
            <div className="text-gray-200 pr-1">
              <MdVisibility className="text-2xl" />
            </div>
            <div>
              <div className="text-gray-200 text-sm sm:base">Visibility</div>
              <div className="text-white text-2xl sm:text-3xl font-bold">
                {timestamps[hour].visibility}%
              </div>
            </div>
          </div>
        </div>
      </div>

      <div className="w-screen text-white" style={{ height: "35%" }}>
        <div className="flex items-center pl-2 sm:pl-8">
          {determineNext5Days().map((e, i) => {
            return (
              <div
                className="px-2 py-1 mx-2 lg:mb-2 rounded-lg cursor-pointer"
                style={day === i ? blurredChip : {}}
                onClick={() => {
                  setHour(0);
                  setDay(i);
                }}
                key={i}
              >
                {e}
              </div>
            );
          })}
        </div>

        <div className="flex justify-around px-8 pt-6 sm:pt-5">
          {timestamps.map((e: any, index: number) => {
            return (
              <div
                key={index}
                className="h-40 w-40 flex flex-col cursor-pointer"
                style={{
                  boxShadow: "0 0 15px 1px rgba(0, 0, 0, 0.75)",
                  backdropFilter: "blur(2px)",
                  transform: hour === index ? "scale(1.1)" : "",
                  zIndex: hour === index ? 2 : 1,
                }}
                onClick={() => setHour(index)}
              >
                <div className="pt-2 pl-2">{e.time}</div>
                <div className="flex-grow"></div>
                <div className="pl-1 sm:pl-2 pb-1 sm:pb-2">
                  <div className="text-2xl font-bold">{e.temp}Β°C</div>
                  {hour === index ? (
                    <div className="text-xs sm:text-base">
                      Feels like {e.feelsLike}Β°
                    </div>
                  ) : null}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
}

export default Desktop;
Enter fullscreen mode Exit fullscreen mode

Challenge for you can be to separate this huge chunk of code into smaller components.

Welcome to React Hook. The hooks are amazing. I was wondering why the dev community makes all this drama about hooks. I didn't know anything about React back then. But after learning, I realised that it's a nice developer experience.

Here is the power of Javascript - callbacks.

Challenge for you could be to show the time of these cities. They are not in the same timezone, so its gonna be interesting.

Life without high-order functions would be painful.


Step 10

Utils.tsx

There is a lot of functionality that needs to be shared between components. Don't clutter code with duplications.

The functionality that we will adapt according to API is changing icons and gifs.

It's hardcoded. If the project was real-life, it will be through RegEx and loops. But for this purpose, the switch will do the job.

To not clutter already long post, here is the code of Utils.tsx. Path: ./src/components/Utils.tsx


Step 11

Prepare for production

./postcss.config.js

const purgecss = require("@fullhuman/postcss-purgecss")({
  content: [
    "./src/**/*.html",
    "./src/**/*.ts",
    "./src/**/*.tsx",
    "./public/index.html",
  ],

  defaultExtractor: (content) => {
    const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [];

    const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || [];

    return broadMatches.concat(innerMatches);
  },
});

const cssnano = require("cssnano");

module.exports = {
  plugins: [
    require("tailwindcss"),
    require("autoprefixer"),
    cssnano({
      preset: "default",
    }),
    purgecss,
  ],
};
Enter fullscreen mode Exit fullscreen mode

./package.json

"build:tailwind": "tailwind build src/tailwind.css -o src/tailwind.output.css"
Enter fullscreen mode Exit fullscreen mode

change to

"build:tailwind": "postcss src/tailwind.css -o src/tailwind.output.css"
Enter fullscreen mode Exit fullscreen mode

Run npm run build and you will get rid of the unused Tailwind classes and end up with ~3kb CSS file.

There's an option for passing ENV argument into npm build and minimizing CSS only for production, but let's keep it simple here.

You may serve production build with the static server. You should receive a manual in the terminal after npm run build.

Voila!


Backstory

Why I built this application?

  • To get a taste of React, Redux, Typescript, and Tailwind. I've learned those in 3 days.

Why Redux in the ultra-small application?

  • To find out why the whole Internet complains about Redux... but it's not that scary!

Why bother posting it?

  • Someone is gonna find it useful. More content - better.

Can't wait to learn more about these web technologies. 🧐


The end

I hope you learned something from my first post. I thought that post would be much shorter. Even in a simple application is hard to cover all parts.

Thank you for reading. I hope I helped you. A well thought out critique is welcome.

πŸ’– πŸ’ͺ πŸ™… 🚩
keno_sej
Kenan Sejmenović

Posted on August 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related