diff --git a/.eslintrc.js b/.eslintrc.js index 601f4a4..8699457 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,6 @@ module.exports = { es6: true, node: true, }, - extends: ["react-app"], globals: { Atomics: "readonly", SharedArrayBuffer: "readonly", @@ -15,5 +14,6 @@ module.exports = { }, ecmaVersion: 2018, }, + extends: ["react-app"], rules: {}, }; diff --git a/src/App.test.js b/src/App.test.js index 23c181f..0a190c9 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -2,6 +2,19 @@ import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; +const originalFetch = window.fetch; +const mockFetch = jest.fn(() => new Promise(() => {})); + +beforeAll(() => { + window.fetch = mockFetch; + global.fetch = mockFetch; +}); + +afterAll(() => { + window.fetch = originalFetch; + global.fetch = originalFetch; +}); + it("renders without crashing", () => { const div = document.createElement("div"); ReactDOM.render(, div); diff --git a/src/components/weatherWidget/WeatherWidget.css b/src/components/weatherWidget/WeatherWidget.css new file mode 100644 index 0000000..6a9824d --- /dev/null +++ b/src/components/weatherWidget/WeatherWidget.css @@ -0,0 +1,72 @@ +.weather-widget { + display: inline-flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 1.1rem; + margin-top: 1.5rem; + border: 1px solid; + border-radius: 18px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); + min-width: 260px; + max-width: 360px; +} + +.weather-widget__header { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.weather-widget__label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.weather-widget__location { + font-size: 0.95rem; + line-height: 1.2; +} + +.weather-widget__state { + font-size: 0.95rem; + line-height: 1.4; +} + +.weather-widget__content { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.weather-widget__icon { + font-size: 2rem; + width: 2rem; + text-align: center; +} + +.weather-widget__reading { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.weather-widget__temperature { + font-size: 2rem; + font-weight: 700; + line-height: 1; + font-family: "Google Sans Bold"; +} + +.weather-widget__condition { + font-size: 0.95rem; + line-height: 1.3; +} + +@media (max-width: 768px) { + .weather-widget { + width: 100%; + max-width: 100%; + } +} diff --git a/src/components/weatherWidget/WeatherWidget.js b/src/components/weatherWidget/WeatherWidget.js new file mode 100644 index 0000000..dba219e --- /dev/null +++ b/src/components/weatherWidget/WeatherWidget.js @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; +import "./WeatherWidget.css"; + +const weatherCodeMap = { + 0: { label: "Clear sky", icon: "fa-sun" }, + 1: { label: "Mostly clear", icon: "fa-sun" }, + 2: { label: "Partly cloudy", icon: "fa-cloud-sun" }, + 3: { label: "Overcast", icon: "fa-cloud" }, + 45: { label: "Fog", icon: "fa-smog" }, + 48: { label: "Rime fog", icon: "fa-smog" }, + 51: { label: "Light drizzle", icon: "fa-cloud-rain" }, + 53: { label: "Drizzle", icon: "fa-cloud-rain" }, + 55: { label: "Heavy drizzle", icon: "fa-cloud-showers-heavy" }, + 61: { label: "Light rain", icon: "fa-cloud-rain" }, + 63: { label: "Rain", icon: "fa-cloud-showers-heavy" }, + 65: { label: "Heavy rain", icon: "fa-cloud-showers-heavy" }, + 71: { label: "Light snow", icon: "fa-snowflake" }, + 73: { label: "Snow", icon: "fa-snowflake" }, + 75: { label: "Heavy snow", icon: "fa-snowflake" }, + 80: { label: "Rain showers", icon: "fa-cloud-showers-heavy" }, + 81: { label: "Rain showers", icon: "fa-cloud-showers-heavy" }, + 82: { label: "Violent rain showers", icon: "fa-bolt" }, + 95: { label: "Thunderstorm", icon: "fa-bolt" }, + 96: { label: "Thunderstorm with hail", icon: "fa-bolt" }, + 99: { label: "Thunderstorm with hail", icon: "fa-bolt" }, +}; + +function getWeatherInfo(weatherCode) { + return weatherCodeMap[weatherCode] || { + label: "Weather update", + icon: "fa-cloud-sun", + }; +} + +export default function WeatherWidget({ + theme, + label = "Pontianak, West Borneo", + latitude = 0.0263, + longitude = 109.3425, +}) { + const [status, setStatus] = useState("loading"); + const [weather, setWeather] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + let isActive = true; + + if (typeof window === "undefined" || typeof window.fetch !== "function") { + setStatus("error"); + setError("Weather data is unavailable in this browser."); + return undefined; + } + + setStatus("loading"); + setError(""); + + const request = window.fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&timezone=Asia%2FJakarta` + ); + + if (!request || typeof request.then !== "function") { + setStatus("error"); + setError("Weather request could not be started."); + return undefined; + } + + request + .then((response) => { + if (!response.ok) { + throw new Error(`Weather request failed with status ${response.status}.`); + } + + return response.json(); + }) + .then((data) => { + if (!isActive) { + return; + } + + if (!data.current_weather) { + throw new Error("Weather data is missing from the response."); + } + + setWeather(data.current_weather); + setStatus("success"); + }) + .catch((fetchError) => { + if (!isActive) { + return; + } + + setStatus("error"); + setError(fetchError.message); + }); + + return () => { + isActive = false; + }; + }, [latitude, longitude]); + + const weatherInfo = weather ? getWeatherInfo(weather.weathercode) : null; + + return ( +
+
+ + Live temperature (°C) + + + {label} + +
+ + {status === "loading" && ( +
+ Loading temperature... +
+ )} + + {status === "error" && ( +
+ {error} +
+ )} + + {status === "success" && weather && weatherInfo && ( +
+