Todos estamos de acuerdo que el Covid-19 dió un giro de 360º a la manera de convivir y subsistir como sociedad a nivel mundial (A menos que hayas estado debajo de una piedra). Por allá del 2020 fueron fechas complicadas pero al momento de escribir esto, las cosas van mejorando gracias a una vacunación globalmente satisfactoria.
Quise tomar la información de una API REST pública y manejar los datos a mi antojo para poder hacer representaciones visuales en una interfaz web, para este ejercicio tomé esta API.
- Llegué a darme cuenta que algunas horas del día no suele estar disponible la información completa, aún no tengo el horario exacto.
Si deseas ver el código completo de la aplicación podrías descargarlo en el repositorio.
Para iniciar los servicios con docker-compose hay que ejecutar en la raíz del proyecto:
docker-compose up
Esto expone el servidor de desarrollo en http://localhost:3000/.
Continuaré con la explicación del código pero antes mostraré un poco la estructura del proyecto a nivel directorios que contiene lo siguiente:
Dentro del directorio src/components se encuentran algunos componentes de uso más general, el directorio src/pages mostrará las páginas que sirven a react-router (Definido dentro del archivo App.js o componente principal), dentro de src/utils encontramos un par de métodos generales que explicaré más adelante.
Components
Skeleton: Un componente intermediario para renderizar componentes páginas dentro de un container, donde solo el children genera un render nuevo de una página cuando navegamos en la aplicación.
import React from "react";
import Header from "./components/header";
export default function Skeleton({ children }) {
return (
<div className="ml-auto mb-6">
<Header />
<div className="px-6 pt-6">{children}</div>
</div>
);
}
Dentro de este componente encontramos un header que es una topbar para realizar una búsqueda por país y entre otra lógica, el código es el siguiente:
import React, { useEffect, useState } from "react";
import Select from "react-select";
import toast from "react-hot-toast";
import { FaShareAlt, FaSearch } from "react-icons/fa";
import { MdOutlineKeyboardReturn } from "react-icons/md";
import getCountries from "../../../utils/getCountries";
import { useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
export default function Header() {
const location = useLocation();
const navigate = useNavigate();
const [selectedValue, setSelectedValue] = useState("");
const [countries, setCountries] = useState(null);
useEffect(() => {
if (!countries) {
const fetchCountries = async () => {
setCountries(await getCountries());
};
fetchCountries();
}
}, [countries]);
const onClick = () => {
if (!selectedValue) return;
navigate(`/country/${selectedValue}`);
};
return (
<div className="sticky z-10 top-0 h-16 border-b py-2.5 bg-white">
<div className="px-6 flex items-center justify-between space-x-4">
{location?.pathname === "/" ? (
<div className="flex space-x-4">
<div className="w-56">
<Select
options={countries}
placeholder="Buscar por país"
menuPlacement="auto"
menuPosition="fixed"
onChange={(e) => setSelectedValue(e.value)}
/>
</div>
<button
className="w-10 h-10 rounded-xl border bg-amber-50 focus:bg-gray-100 active:bg-gray-200"
onClick={onClick}
>
<FaSearch className="h-5 w-5 m-auto text-gray-600" />
</button>
</div>
) : (
<div className="flex space-x-4">
<button
onClick={() => navigate("/")}
className="w-10 h-10 rounded-xl border bg-amber-50 focus:bg-gray-100 active:bg-gray-200"
>
<MdOutlineKeyboardReturn className="h-5 w-5 m-auto text-gray-600" />
</button>
</div>
)}
<div className="flex space-x-4">
<button
className="w-10 h-10 rounded-xl border bg-amber-50 focus:bg-gray-100 active:bg-gray-200"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast("Url copiada", {
icon: "😎",
});
}}
>
<FaShareAlt className="h-5 w-5 m-auto text-gray-600" />
</button>
</div>
</div>
</div>
);
}
Loading: Componente simple que muestra un overlay con una animación. En algunos casos la API puede llegar a demorar y el objetivo es mostrar este componente en lo que obtenemos una respuesta.
import React from "react";
export default function Loading({ show = false }) {
return (
<>
{show && (
<div className="fixed w-full top-0 left-0 bottom-0 right-0 z-20 bg-gray-300">
<div className="flex items-center justify-center space-x-2 animate-pulse m-28">
<div className="w-8 h-8 bg-blue-400 rounded-full"></div>
<div className="w-8 h-8 bg-blue-400 rounded-full"></div>
<div className="w-8 h-8 bg-blue-400 rounded-full"></div>
</div>
</div>
)}
</>
);
}
Utils
Con la API que estamos consultado obtenemos datos con el método GET (si no tienes idea de API o REST puedes leer aquí), así que generé una función que se encarga de realizar las llamadas HTTP para cada acción descrita en la documentación de la API.
Quise ser empático con sus servicios y guardó en el storage del navegador la información devuelta, algo muy lindo para no cargar la mano al servidor de la API cada que se visita una página de la aplicación.
export default function get(path) {
const data = getStorageData(path);
if (data) {
console.log("recover data from storage...", data);
return data;
}
return new Promise((resolve, reject) => {
try {
fetch(`https://covid-api.mmediagroup.fr/v1/${path}`)
.then((response) => response.json())
.then((data) => {
const now = new Date();
// guardamos en storage la data por 1 hora
window.localStorage.setItem(
path,
JSON.stringify({
value: data,
expired: now.getTime() + 3600 * 1000,
})
);
console.log("get data from server and save storage...", path);
resolve(data);
});
} catch (error) {
reject(error);
}
});
}
function getStorageData(key) {
const data = localStorage.getItem(key);
if (!data) {
return null;
}
const item = JSON.parse(data);
const now = new Date();
if (now.getTime() > item.expired) {
localStorage.removeItem(key);
return null;
}
return item.value;
}
Estoy de acuerdo que esta información suele actualizarse continuamente, justo por este motivo la información persistida tiene una duración para ser utilizada, de ahí volverá a hacer una llamada para obtener nueva información.
Hay otro método que es utilizado para ir por los países relacionados a la API, con un funcionamiento similar al anterior, la diferencia radica que este no maneja un tiempo de expiración.
export default function getCountries() {
const data = getStorageData("countries");
if (data) {
return data;
}
return new Promise((resolve, reject) => {
try {
fetch(
"https://raw.githubusercontent.com/M-Media-Group/country-json/master/src/countries-master.json"
)
.then((response) => response.json())
.then((data) => {
const dataSelect = data.map((e) => ({
...e,
label: e?.country,
value: e?.abbreviation,
}));
window.localStorage.setItem("countries", JSON.stringify(dataSelect));
resolve(dataSelect);
});
} catch (error) {
reject(error);
}
});
}
function getStorageData(key) {
const data = localStorage.getItem(key);
if (!data) {
return null;
}
return JSON.parse(data);
}
Páginas
Para las gráficas mostradas en la app se utilizó la librería de javascript llamada recharts.
La navegación consta de dos páginas, información por país y el home, este último tiene el siguiente código.
import React, { useEffect, useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import get from "../../utils/get";
import porcentDeath from "./utils/porcentDeath";
export default function Home() {
const [data, setData] = useState(null);
const [globalData, setGlobalData] = useState({});
useEffect(() => {
if (!data) {
const fetch_data = async () => {
const info = await get("cases");
if (info) {
const info_array = Object.entries(info);
setData(() => porcentDeath(info_array));
setGlobalData({
...info?.Global?.All,
// obtenemos algunos porcentajes
porcent_death:
(info?.Global?.All?.deaths / info?.Global?.All?.confirmed) * 100,
porcent_confirmed_population:
(info?.Global?.All?.confirmed / info?.Global?.All?.population) *
100,
porcent_death_population:
(info?.Global?.All?.deaths / info?.Global?.All?.population) * 100,
});
}
};
fetch_data();
}
}, [data]);
return (
<div className="grid gap-6">
<div className="md:col-span-2 lg:col-span-1">
<div className="h-full py-8 px-6 space-y-6 rounded-xl border border-gray-200 bg-white">
<h5 className="text-xl text-gray-600 text-center">
Actividad Global
</h5>
<div className="text-gray-600 text-center">
<p>
<strong>Población</strong>:{" "}
{globalData &&
parseInt(globalData?.population).toLocaleString("en-US")}
</p>
<p>
<strong>Casos confirmados</strong>:{" "}
{globalData &&
parseInt(globalData?.confirmed).toLocaleString("en-US")}
</p>
<p>
<strong>Cantidad de muertes</strong>:{" "}
{globalData &&
parseInt(globalData?.deaths).toLocaleString("en-US")}{" "}
</p>
<p>
<strong>Porcentaje de muertes sobre casos confirmados</strong>:{" "}
{globalData &&
!isNaN(globalData?.porcent_death) &&
globalData?.porcent_death.toFixed(2)}{" "}
%
</p>
<p>
<strong>
Porcentaje de contagios confirmados de la población mundial
</strong>
:{" "}
{globalData &&
!isNaN(globalData?.porcent_confirmed_population) &&
globalData?.porcent_confirmed_population.toFixed(2)}{" "}
%
</p>
<p>
<strong>Porcentaje de muertes de la población mundial</strong>:{" "}
{globalData &&
!isNaN(globalData?.porcent_death_population) &&
globalData?.porcent_death_population.toFixed(2)}{" "}
%
</p>
</div>
</div>
</div>
<div className="md:col-span-2 lg:col-span-1">
<div className="h-full py-8 px-6 space-y-6 rounded-xl border border-gray-200 bg-white">
<h5 className="text-xl text-gray-600 text-center">
Porcentaje de muerte
</h5>
<p className="text-gray-600 text-center">
Los 10 paises con mayor porcentaje de muertes sobre casos
confirmados
</p>
<ResponsiveContainer width="100%" height={500}>
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
barSize={20}
>
<XAxis
dataKey="key"
scale="point"
padding={{ left: 10, right: 10 }}
/>
<YAxis />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" />
<Bar
name="Porcent death"
dataKey="porcent_death"
fill="#1b7ce4"
background={{ fill: "#eee" }}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
Este componente Home hace uso del function get(path), declarado en src/utils/get.js para obtener la información de la API.
Incluí un buscador por país:
Al seleccionar alguno y dar un click al botón de buscar, nos dará información más detallada del país:
El componente Country es la página declarada en el router para visualizar las estadísticas por país, el código es el siguiente:
import React, { useEffect, useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useParams } from "react-router-dom";
import Select from "react-select";
import get from "../../utils/get";
import Loading from "../../components/loading/loading";
import { tranformDataMonthly, years } from "./utils/tranformDataMonthly";
export default function Country() {
const { name } = useParams();
const [allYears] = useState(() => years());
const [selectedYear, setSelectedYear] = useState(() =>
String(new Date().getFullYear())
);
const [data, setData] = useState(null);
useEffect(() => {
if (!data) {
const fetchData = async () => {
const info = await get(`history?ab=${name}&status=deaths`);
if (info) {
setData({
...info?.All,
datesMap: tranformDataMonthly(info?.All?.dates),
});
}
};
fetchData();
}
}, [data, name]);
return (
<div className="grid gap-6">
<Loading show={!data ? true : false} />
<div className="md:col-span-2 lg:col-span-1">
<div className="h-full py-8 px-6 space-y-6 rounded-xl border border-gray-200 bg-white">
<h5 className="text-xl text-gray-600 text-center">
Estadísticas de {data?.country}, {data?.continent}
</h5>
<div className="text-gray-600 text-center">
<p>
<strong>Población</strong>:{" "}
{data && parseInt(data?.population).toLocaleString("en-US")}
</p>
<p>
<strong>Superficie</strong>:{" "}
{data && parseInt(data?.sq_km_area).toLocaleString("en-US")} km²
</p>
<p>
<strong>Densidad de Población</strong>:{" "}
{data &&
Math.round(
parseInt(data?.population) / parseInt(data?.sq_km_area)
)}{" "}
</p>
<div className="w-56">
<Select
options={allYears}
placeholder="Selecciona año"
menuPlacement="auto"
menuPosition="fixed"
onChange={(e) => e && setSelectedYear(e.value)}
defaultValue={{ value: selectedYear, label: selectedYear }}
/>
</div>
Muertes mensuales
</div>
<ResponsiveContainer width="100%" height={500}>
<BarChart
data={
(data &&
data.datesMap.filter((e) => e.year === selectedYear)) ||
[]
}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
barSize={20}
>
<XAxis
dataKey="monthStr"
scale="point"
padding={{ left: 10, right: 10 }}
/>
<YAxis />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" />
<Bar
name="Muertes"
dataKey="deaths"
fill="#1b7ce4"
background={{ fill: "#eee" }}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
Es importante recalcar que para armar la visualización de las gráficas en este componente, existe una función que hace una transformación de la información devuelta por la API, por decir una caja negra en src/pages/country/utils/tranformDataMonthly.js, para que los elementos escritos en JSX sean solamente presentacionales y no cuenten con lógica alguna.
function tranformDataMonthly(array) {
const dates = Object.entries(array);
if (!Array.isArray(dates)) return [];
const lastDeathsMonth = dates.filter((e) => {
const year = e[0].slice(0, 4);
const month = e[0].slice(5, 7);
if (!year && !month) return false;
const lastDateOfMonth = new Date(parseInt(year), parseInt(month), 0);
let lastDayOfMonth =
lastDateOfMonth.getDate() < 10
? `0${lastDateOfMonth.getDate()}`
: String(lastDateOfMonth.getDate());
// solo el ultimo dia del mes para comparar mes anterior
return e[0] === `${year}-${month}-${lastDayOfMonth}`;
});
return lastDeathsMonth.map((e) => {
const year = e[0].slice(0, 4);
const month = e[0].slice(5, 7);
let previousMonth = (parseInt(month) === 1 && 12) || parseInt(month) - 1;
let previousYear = previousMonth === 12 ? parseInt(year) - 1 : year;
const previousDate = lastDeathsMonth.find(
(n) =>
n[0].search(
`${previousYear}-${
previousMonth < 10 ? `0${previousMonth}` : previousMonth
}`
) !== -1
);
let deaths = 0;
// el total de muertes del mes actual se resta las muertes del mes anterior
if (previousDate) {
deaths = e[1] - previousDate[1];
}
return {
year,
month,
monthStr: months[month],
deaths,
};
});
}
Si eres observador, existe un Select que muestra los años desde que inició el Covid-19 globalmente, cada año arroja en una gráfica con la cantidad de muertes mensuales. También existe un método que obtiene el listado de estos años.
const years = () => {
let startYear = new Date(2020, 1, 1).getFullYear();
let endYear = new Date().getFullYear();
let allYears = [];
while (startYear <= endYear) {
allYears.push({
value: String(startYear),
label: String(startYear),
});
startYear++;
}
return allYears;
};
El objetivo es devolver un array de años iniciando en el 2020, hasta el año actual.
Conclusiones
La aplicación descrita puede seguir creciendo con muchos más elementos que ofrece la API, con un poco más de creatividad y de ingenio poder aprovechar otros endpoints para generar otras páginas con otra información, realizar promedios y señalar la desviación estándar de los datos.
¿Podrías continuar con el reto?
Saludos.