Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,347 changes: 1,334 additions & 13 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,36 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"antd": "^5.24.4",
"axios": "^1.8.3",
"bootstrap": "^5.3.3",
"cities.json": "^1.1.50",
"framer-motion": "^12.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"vite": "^6.2.0"
"jsdom": "^26.1.0",
"vite": "^6.2.0",
"vitest": "^3.2.4"
}
}
131 changes: 36 additions & 95 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,110 +1,51 @@
// src/App.jsx

import React, { useState, useEffect } from "react";
import axios from "axios";
import { Input, List, Card, Spin } from "antd";
import { motion } from "framer-motion";

const { Search } = Input;
import Search from "./components/Search.jsx";
import WeatherCard from "./components/WeatherCard.jsx";
import {motion} from "framer-motion";
import { useEffect, useState } from 'react';
import { getCityByLocation, isCityExists } from './utils/data/city.js';
import {fetchWeather} from "./api/weather.js";
import { getCurrentLocation } from './utils/geolocation.js';

const WeatherApp = () => {
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState([]);
const [weather, setWeather] = useState({
loading: false,
data: null,
error: false,
});
const [input, setInput] = useState({});
const [weather, setWeather] = useState(null);

useEffect(() => {
if (input.length > 2) {
fetchCitySuggestions();
} else {
setSuggestions([]);
}
}, [input]);

const fetchCitySuggestions = async () => {
const apiKey = "55bcb10dcc10a878c49a5e958b020ded"; // Replace with your OpenWeather API key
const url = `https://api.openweathermap.org/data/2.5/find?q=${input}&type=like&sort=population&cnt=5&appid=${apiKey}`;

try {
const res = await axios.get(url);
setSuggestions(res.data.list.map(city => city.name));
} catch (error) {
console.error("Error fetching city suggestions:", error);
getCurrentLocation()
.then(location => {
getCityByLocation(location).then(city => {
if (city) onChange(city);
});
})
.catch(error => {
console.info(error);
});
}, []);

const onChange = async (cityName) => {
setInput(cityName);
if (!isCityExists(cityName)) {
setWeather(null);
return;
}
};

const fetchWeather = async (city) => {
setWeather({ ...weather, loading: true, error: false });
setInput(city);
setSuggestions([]);

const apiKey = "55bcb10dcc10a878c49a5e958b020ded"; // Replace with your OpenWeather API key
const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&units=metric&appid=${apiKey}`;

try {
const res = await axios.get(url);
setWeather({ data: res.data, loading: false, error: false });
} catch (error) {
if (error.response && error.response.status === 404) {
setWeather({ ...weather, data: null, error: true });
} else {
console.error("API Error:", error);
}
}
};
const res = await fetchWeather(cityName);
setWeather(res);
}

return (
<div className="container text-center mt-5">
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1 }}>
<motion.h1 initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 1}}>
Weather App
</motion.h1>
<Search
placeholder="Enter city name..."
value={input}
onChange={(e) => setInput(e.target.value)}
onSearch={fetchWeather}
className="mb-3"
size="large"
/>
{suggestions.length > 0 && (
<List
bordered
dataSource={suggestions}
renderItem={(city) => (
<List.Item onClick={() => fetchWeather(city)}>{city}</List.Item>
)}
className="mb-3"
/>
)}
{weather.loading && <Spin size="large" />}
{weather.error && <p className="text-danger">City not found</p>}

{weather.data && (
<motion.div
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<Card className="mx-auto" style={{ width: 400 }}>
<h2>{weather.data.name}, {weather.data.sys.country}</h2>
<p>{new Date().toLocaleDateString()}</p>
{weather.data.weather[0].icon ? (
<img
src={`https://openweathermap.org/img/wn/${weather.data.weather[0].icon}@2x.png`}
alt={weather.data.weather[0].description}
/>
) : (
<p>No icon available</p>
)}
<h3>{Math.round(weather.data.main.temp)}°C</h3>
<p>{weather.data.weather[0].description.toUpperCase()}</p>
<p>Wind Speed: {weather.data.wind.speed} m/s</p>
</Card>
</motion.div>
)}
<Search value={input} onChange={onChange} />

<div className={"mt-lg-5 mx-auto d-flex justify-content-center align-items-center"}>
{weather && (
<WeatherCard weather={weather} />
)}
</div>
</div>
);
};
Expand Down
48 changes: 48 additions & 0 deletions src/App.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {describe, expect, it, vi} from "vitest";
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import WeatherApp from "./App.jsx";
import { getCurrentLocation } from './utils/geolocation.js';

vi.mock('./utils/geolocation.js', () => {
return {
getCurrentLocation: vi.fn(),
};
});

describe("<WeatherCard />", () => {
it("renders without crashing", async () => {
getCurrentLocation.mockImplementationOnce(() => Promise.reject({
code: 1,
message: "User denied Geolocation"
}));

render(<WeatherApp/>);

const input = await screen.getByPlaceholderText("Enter city...");
fireEvent.change(input, {
target: {
value: "Hamburg"
}
});

await waitFor(async () => {
const card = await screen.getByTestId("weather-card");
expect(card).toBeInTheDocument();
});
});

it('should render with Winterhude city', async () => {
getCurrentLocation.mockImplementationOnce(() => Promise.resolve({
latitude: 53.59,
longitude: 9.99
}));

render(<WeatherApp/>);

await waitFor(async () => {
const card = await screen.getByTestId("weather-card");
expect(card).toBeInTheDocument();
expect(within(card).getByTestId("title")).toHaveTextContent("Winterhude");
});
});
});
12 changes: 12 additions & 0 deletions src/api/weather.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import axios from 'axios';

const apiKey = "55bcb10dcc10a878c49a5e958b020ded";
const client = axios.create({
baseURL: 'https://api.openweathermap.org/data/2.5',
});

export const fetchWeather = async (cityName) => {
const result = await client.get(`/weather?q=${cityName}&units=metric&appid=${apiKey}`);

return result.data;
}
11 changes: 11 additions & 0 deletions src/api/weather.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {describe, it, expect} from 'vitest';
import { fetchWeather } from './weather.js';


describe('weather api', () => {
it('Should return an weather for city', async () => {
const weather = await fetchWeather("Hamburg");

expect(weather.name).toEqual("Hamburg");
});
})
35 changes: 35 additions & 0 deletions src/components/Search.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {getCities} from "../utils/data/city.js";
import {AutoComplete} from 'antd';
import { Input } from 'antd';

const Search = ({ value, onChange }) => {
const transformOptions = () => {
let cities = getCities();

return cities.map((city) => {
return {
value: city,
}
});
}


return (
<AutoComplete
value={value}
onChange={onChange}
style={{
width: "100%",
}}
options={transformOptions()}
filterOption={(input, option) => {
return option.value.startsWith(input);
}}

>
<Input.Search size="large" enterButton placeholder="Enter city..." />
</AutoComplete>
)
};

export default Search;
29 changes: 29 additions & 0 deletions src/components/Search.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {render, screen, fireEvent, waitFor} from "@testing-library/react";
import {describe, it, expect, vi} from "vitest";
import Search from "./Search";

vi.mock("../utils/data/city.js", () => {
return {
getCities: () => ["Hamburg", "Berlin", "Munich"],
};
});

describe("<Search />", () => {
it("shows suggestions and fetches weather", async () => {
render(<Search value={""} onChange={() => console.log("onChange")} />);

const input = await screen.getByPlaceholderText("Enter city...");

fireEvent.change(input, {
target: {
value: "Hambur"
}
});

await waitFor(() => {
expect(screen.getByRole("option", { name: "Hamburg" })).toBeInTheDocument();
});

expect(screen.queryByText("Berlin")).not.toBeInTheDocument();
});
})
27 changes: 27 additions & 0 deletions src/components/WeatherCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {motion} from "framer-motion";
import {Card} from "antd";

const WeatherCard = ({weather}) => {
return (
<motion.div
initial={{y: -20, opacity: 0}}
animate={{y: 0, opacity: 1}}
transition={{duration: 0.5}}
>
<Card style={{width: 400}} data-testid="weather-card">
<h2 data-testid="title">{weather.name}, {weather.sys.country}</h2>
<p data-testid="date">{new Date().toLocaleDateString()}</p>
{weather.weather[0].icon ? (<img
data-testid="icon"
src={`https://openweathermap.org/img/wn/${weather.weather[0].icon}@2x.png`}
alt={weather.weather[0].description}
/>) : (<p data-testid="icon">No icon available</p>)}
<h3 data-testid="temp">{Math.round(weather.main.temp)}°C</h3>
<p data-testid="description">{weather.weather[0].description.toUpperCase()}</p>
<p data-testid="wind">Wind Speed: {weather.wind.speed} m/s</p>
</Card>
</motion.div>
)
}

export default WeatherCard;
Loading