diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index f8b1ce02cec..600d11fc62e 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -3325,7 +3325,12 @@ "ftmi": { "title": "275", "intro": [] }, "sgau": { "title": "276", "intro": [] }, "clak": { "title": "277", "intro": [] }, - "fcom": { "title": "278", "intro": [] }, + "lab-weather-app": { + "title": "Build a Weather App", + "intro": [ + "In this lab you will build a Weather App using an API to fetch the data." + ] + }, "ffpt": { "title": "279", "intro": [] }, "lrof": { "title": "280", "intro": [] }, "vyzp": { "title": "281", "intro": [] }, diff --git a/client/src/pages/learn/full-stack-developer/lab-weather-app/index.md b/client/src/pages/learn/full-stack-developer/lab-weather-app/index.md new file mode 100644 index 00000000000..19b4be6285a --- /dev/null +++ b/client/src/pages/learn/full-stack-developer/lab-weather-app/index.md @@ -0,0 +1,9 @@ +--- +title: Introduction to the Build a Weather App +block: lab-weather-app +superBlock: full-stack-development +--- + +## Introduction to the Build a Weather App + +In this lab you will build a Weather App using an API to fetch the data. diff --git a/curriculum/challenges/_meta/lab-weather-app/meta.json b/curriculum/challenges/_meta/lab-weather-app/meta.json new file mode 100644 index 00000000000..4ffeb9c5cd2 --- /dev/null +++ b/curriculum/challenges/_meta/lab-weather-app/meta.json @@ -0,0 +1,11 @@ +{ + "name": "Build a Weather App", + "isUpcomingChange": true, + "usesMultifileEditor": true, + "dashedName": "lab-weather-app", + "superBlock": "full-stack-developer", + "challengeOrder": [{ "id": "66f12a88741aeb16b9246c59", "title": "Build a Weather App" }], + "helpCategory": "JavaScript", + "blockType": "lab", + "blockLayout": "link" +} diff --git a/curriculum/challenges/english/25-front-end-development/lab-weather-app/66f12a88741aeb16b9246c59.md b/curriculum/challenges/english/25-front-end-development/lab-weather-app/66f12a88741aeb16b9246c59.md new file mode 100644 index 00000000000..4c9238a1eba --- /dev/null +++ b/curriculum/challenges/english/25-front-end-development/lab-weather-app/66f12a88741aeb16b9246c59.md @@ -0,0 +1,876 @@ +--- +id: 66f12a88741aeb16b9246c59 +title: Build a Weather App +challengeType: 14 +dashedName: lab-weather-app +demoType: onClick +--- + +# --description-- + +**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab. + +You will use a weather API. The output data has the following format: + +```json +{ + "weather": [ + { + "main": "Clear", + "description": "clear sky", + "icon": "https://cdn.freecodecamp.org/weather-icons/01n.png" // icon representing the weather + } + ], + "main": { + "temp": 2.62, // temperature in C + "feels_like": 0.84, // temperature in C + "temp_min": 1.72, // min temperature of the day in C + "temp_max": 3.49, // max temperature of the day in C + "pressure": 1010, // atmospheric pressure in hPa + "humidity": 81, // humidity in % + }, + "visibility": 10000, // distance in meters + "wind": { + "speed": 1.79, // speed of the wind in m/s + "deg": 285, // orientation of the wind in degrees + "gust": 3.13 // gust speed in m/s + }, + "name": "London", +} +``` + +**User Stories:** + +1. You should have a `button` element with an `id` of `get-forecast`. + +1. You should have a `select` element with seven `option` elements nested within it. The first option should have an empty string as its text and `value` attribute. The rest should have the follow for their text and values (with the value being lowercase): + - New York + - Los Angeles + - Chicago + - Paris + - Tokyo + - London + +1. If no city is selected, pressing the button should do nothing. + +1. If a city is selected, elements to show the weather should appear: + + - You should have an `img` element with the id `weather-icon` for displaying the weather icon. + + - You should have an element with the id `main-temperature` for displaying the main temperature. + + - You should have an element with the id `feels-like` for displaying what the temperature feels like. + + - You should have an element with the id `humidity` for displaying the amount of humidity in air. + + - You should have an element with the id `wind` element for displaying the wind speed. + + - You should have an element with the id `wind-gust` element for displaying the wind gust. + + - You should have an element with the id `weather-main` element for displaying the main weather type. + + - You should have an element with the id `location` element for displaying the current location. + +1. You should have an asynchronous function named `getWeather` that fetches the weather information from the `https://weather-proxy.freecodecamp.rocks/api/city/` API and returns it. Note that this API returns data using the metric system, that means m/s for wind speed, and Celsius for the temperature. + +1. The `getWeather` asynchronous function should accept a city as its argument. + +1. You should handle any errors that occur within the `getWeather` function and log them to the console. + +1. You should have an asynchronous `showWeather` function that accepts a city as parameter. + +1. The `showWeather` function should call the `getWeather` function to retrieve the weather data for the selected city from the dropdown. + +1. If the `getWeather` function had an error, the app should only show an alert that says `Something went wrong, please try again later`. + +1. If the data from `getWeather` are usable, the `showWeather` function should display the weather data in the corresponding elements. If a certain value from the API is `undefined`, you should write `N/A` in the corresponsing element. + +NOTE: The tests will take time to complete. As long as you see `// running tests` in the console, they are being executed. + +# --hints-- + +You should have a `button` element with an `id` of `get-forecast`. + +```js +assert.exists(document.querySelector('button#get-forecast')); +``` + +You should have a `select` element. + +```js +assert.exists(document.querySelector('select')); +``` + +Inside the `select` element the first child should be an `option` element with an empty `value` attribute. + +```js +assert.exists(document.querySelector('select > option[value=""]')); +assert.strictEqual(document.querySelector('select > option:first-child').value, ""); +``` + +Inside the `select` element there should be 6 `option` elements, one for each of the following cities: Paris, London, Tokyo, Los Angeles, Chicago, New York. + +```js +["Paris", "London", "Tokyo", "Los Angeles", "Chicago", "New York"].forEach(city => { + const el = document.querySelector(`select > option[value="${city.toLowerCase()}"]`); + assert.exists(el); + assert.strictEqual(el.innerText.trim(), city) +}); +``` + +You should have an `img` element with the id `weather-icon` for displaying the weather icon. + +```js +async () => { + document.querySelector('select').value = "chicago"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("img#weather-icon")); +} +``` + +You should have an element with the id `main-temperature` for displaying the main temperature. + +```js +async () => { + document.querySelector('select').value = "london"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#main-temperature")); +} +``` + +You should have an element with the id `feels-like` for displaying what the temperature feels like. + +```js +async () => { + document.querySelector('select').value = "london"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#feels-like")); +} +``` + +You should have an element with the id `humidity` for displaying the amount of humidity in air. + +```js +async () => { + document.querySelector('select').value = "london"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#humidity")); +} +``` + +You should have an element with the id `wind` element for displaying the wind speed. + +```js +async () => { + document.querySelector('select').value = "new york"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#wind")); +} +``` + +You should have an element with the id `wind-gust` element for displaying the wind gust. + +```js +async () => { + document.querySelector('select').value = "new york"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#wind-gust")); +} +``` + +You should have an element with the id `weather-main` element for displaying the main weather type. + +```js +async () => { + document.querySelector('select').value = "new york"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#weather-main")); +} +``` + +You should have an element with the id `location` element for displaying the current location. + +```js +async () => { + document.querySelector('select').value = "new york"; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.exists(document.querySelector("#location")); +} +``` + +You should have a `showWeather` function. + +```js +assert.isFunction(showWeather); +``` + +You should have a `getWeather` function. + +```js +assert.isFunction(getWeather); +``` + +The `getWeather` function should accept a city as it's only argument and return the JSON from the Weather API. + +```js +async () => { + try { + const city = "chicago"; + const url = `https://weather-proxy.freecodecamp.rocks/api/city/${city}`; + const outputFunction = await getWeather(city); + const json = await fetch(url).then(data => data.json()); + assert.deepEqual(outputFunction, json) + } catch (err) { + throw new Error(err); + } +} +``` + +The `showWeather` function should call the `getWeather` function to get the weather data. + +```js +async () => { + try { + let flag = false; + const temp = getWeather; + getWeather = async (location) => { + flag = true; + return await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${location}`).then(data => data.json()) + } + await showWeather("london"); + getWeather = temp; + assert.isTrue(flag) + } catch (err) { + throw new Error(err); + } +} +``` + +When New York is selected the `showWeather` function should display the data from the API in the respective HTML elements. If the value from the API is `undefined`, you should show `N/A`. + +```js +async () => { + try { + // function that construct an object with the id-value pairs that we expect in the page from an object + const helper = (wobj) => ({ + "weather-icon": wobj.weather[0].icon, + "main-temperature": wobj.main.temp || 'N/A', + "feels-like": wobj.main.feels_like || 'N/A', + humidity: wobj.main.humidity || 'N/A', + wind: wobj.wind.speed || 'N/A', + "wind-gust": wobj.wind.gust || 'N/A', + "weather-main": wobj.weather[0].main || 'N/A', + location: wobj.name || 'N/A' + }) + const city = "new york"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + + + // fetch the expected values from the API to confront + const body = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`).then( + data => data.json() + ) + + await new Promise(resolve => setTimeout(resolve, 800)); + + // construct the object from the data from the API + const extractedData = helper(body); + + // check that all things are in place + for (const id in extractedData) { + if (id === "weather-icon") { + assert.equal(document.querySelector('#weather-icon').src, extractedData[id]); + } else { + assert.include(document.querySelector(`#${id}`).innerText.toLowerCase(), `${extractedData[id]}`.toLowerCase()); + } + } + } catch (err) { + throw new Error(err); + } +} +``` + +When Chicago is selected the `showWeather` function should display the data from the API in the respective HTML elements. If the value from the API is `undefined`, you should show `N/A`. + +```js +async () => { + try { + // function that construct an object with the id-value pairs that we expect in the page from an object + const helper = (wobj) => ({ + "weather-icon": wobj.weather[0].icon, + "main-temperature": wobj.main.temp || 'N/A', + "feels-like": wobj.main.feels_like || 'N/A', + humidity: wobj.main.humidity || 'N/A', + wind: wobj.wind.speed || 'N/A', + "wind-gust": wobj.wind.gust || 'N/A', + "weather-main": wobj.weather[0].main || 'N/A', + location: wobj.name || 'N/A' + }) + const city = "chicago"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + + + // fetch the expected values from the API to confront + const body = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`).then( + data => data.json() + ) + + await new Promise(resolve => setTimeout(resolve, 800)); + + // construct the object from the data from the API + const extractedData = helper(body); + + // check that all things are in place + for (const id in extractedData) { + if (id === "weather-icon") { + assert.equal(document.querySelector('#weather-icon').src, extractedData[id]); + } else { + assert.include(document.querySelector(`#${id}`).innerText.toLowerCase(), `${extractedData[id]}`.toLowerCase()); + } + } + } catch (err) { + throw new Error(err); + } +} +``` + +When London is selected the `showWeather` function should display the data from the API in the respective HTML elements. If the value from the API is `undefined`, you should show `N/A`. + +```js +async () => { + try { + // function that construct an object with the id-value pairs that we expect in the page from an object + const helper = (wobj) => ({ + "weather-icon": wobj.weather[0].icon, + "main-temperature": wobj.main.temp || 'N/A', + "feels-like": wobj.main.feels_like || 'N/A', + humidity: wobj.main.humidity || 'N/A', + wind: wobj.wind.speed || 'N/A', + "wind-gust": wobj.wind.gust || 'N/A', + "weather-main": wobj.weather[0].main || 'N/A', + location: wobj.name || 'N/A' + }) + const city = "london"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + + + // fetch the expected values from the API to confront + const body = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`).then( + data => data.json() + ) + + await new Promise(resolve => setTimeout(resolve, 800)); + + // construct the object from the data from the API + const extractedData = helper(body); + + // check that all things are in place + for (const id in extractedData) { + if (id === "weather-icon") { + assert.equal(document.querySelector('#weather-icon').src, extractedData[id]); + } else { + assert.include(document.querySelector(`#${id}`).innerText.toLowerCase(), `${extractedData[id]}`.toLowerCase()); + } + } + } catch (err) { + throw new Error(err); + } +} +``` + +When Tokyo is selected the `showWeather` function should display the data from the API in the respective HTML elements. If the value from the API is `undefined`, you should show `N/A`. + +```js +async () => { + try { + // function that construct an object with the id-value pairs that we expect in the page from an object + const helper = (wobj) => ({ + "weather-icon": wobj.weather[0].icon, + "main-temperature": wobj.main.temp || 'N/A', + "feels-like": wobj.main.feels_like || 'N/A', + humidity: wobj.main.humidity || 'N/A', + wind: wobj.wind.speed || 'N/A', + "wind-gust": wobj.wind.gust || 'N/A', + "weather-main": wobj.weather[0].main || 'N/A', + location: wobj.name || 'N/A' + }) + const city = "tokyo"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + + + // fetch the expected values from the API to confront + const body = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`).then( + data => data.json() + ) + + await new Promise(resolve => setTimeout(resolve, 800)); + + // construct the object from the data from the API + const extractedData = helper(body); + + // check that all things are in place + for (const id in extractedData) { + if (id === "weather-icon") { + assert.equal(document.querySelector('#weather-icon').src, extractedData[id]); + } else { + assert.include(document.querySelector(`#${id}`).innerText.toLowerCase(), `${extractedData[id]}`.toLowerCase()); + } + } + } catch (err) { + throw new Error(err); + } +} +``` + +When Los Angeles is selected the `showWeather` function should display the data from the API in the respective HTML elements. If the value from the API is `undefined`, you should show `N/A`. + +```js +async () => { + try { + // function that construct an object with the id-value pairs that we expect in the page from an object + const helper = (wobj) => ({ + "weather-icon": wobj.weather[0].icon, + "main-temperature": wobj.main.temp || 'N/A', + "feels-like": wobj.main.feels_like || 'N/A', + humidity: wobj.main.humidity || 'N/A', + wind: wobj.wind.speed || 'N/A', + "wind-gust": wobj.wind.gust || 'N/A', + "weather-main": wobj.weather[0].main || 'N/A', + location: wobj.name || 'N/A' + }) + const city = "los angeles"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + + + // fetch the expected values from the API to confront + const body = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`).then( + data => data.json() + ) + + await new Promise(resolve => setTimeout(resolve, 800)); + + // construct the object from the data from the API + const extractedData = helper(body); + + // check that all things are in place + for (const id in extractedData) { + if (id === "weather-icon") { + assert.equal(document.querySelector('#weather-icon').src, extractedData[id]); + } else { + assert.include(document.querySelector(`#${id}`).innerText.toLowerCase(), `${extractedData[id]}`.toLowerCase()); + } + } + } catch (err) { + throw new Error(err); + } +} +``` + +If there is an error, your `getWeather` function should log the error to the console. + +```js +const testArr = []; +const temp1 = fetch; +const temp2 = console.log; +const temp3 = console.error; +const temp4 = alert; +async () => { + try { + alert = () => {}; + console.log = obj => {testArr.push(obj.toString())}; + console.error = obj => {testArr.push(obj.toString())}; + fetch = source => {throw new Error("This is a test error");} + await getWeather("chicago"); + assert.include(testArr[0], "This is a test error"); + assert.lengthOf(testArr, 1); + } finally { + fetch = temp1; + console.log = temp2; + console.error = temp3; + alert = temp4; + } +} +``` + +When Paris is selected the app should show an alert with `Something went wrong, please try again later`. + +```js +const testArr = []; +const temp4 = alert; +async () => { + try { + alert = (msg) => {testArr.push(msg)}; + const city = "paris"; + document.querySelector('select').value = city; + document.querySelector('#get-forecast').click(); + await new Promise(resolve => setTimeout(resolve, 800)); + assert.include(testArr[0], "Something went wrong, please try again later"); + assert.lengthOf(testArr, 1); + } finally { + alert = temp4; + } +} +``` + +# --seed-- + +## --seed-contents-- + +```html + + + + + + Weather App + + + + + + + + +``` + +```css + +``` + +```js + +``` + +# --solutions-- + +```html + + + + + + Weather App + + + + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + +
+
+
+
+
+ +
+ Weather for: + + +
+ + + + + +``` + +```css +* { + box-sizing: border-box; +} + +body { + background-color: #32a852; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + row-gap: 40px; + font-size: 20px; + font-family: sans-serif; +} + +.btn-wrap { + min-width: 700px; + display: flex; + justify-content: space-evenly; + column-gap: 20px; + border: 2px solid black; + padding: 20px; + border-radius: 10px; + background-color: #6cd488; + box-shadow: 6px 6px 6px rgba(0,0,0,0.5); +} + +.btn-wrap > span { + text-transform: uppercase; + text-align: center; + margin: auto; +} + +.btn-wrap > span::first-letter { + font-size: 1.3em +} + +#location-selector { + width: 200px; + height: 50px; + font-size: 20px; +} + +#get-forecast { + font-size: 20px; + height: 50px; + width: 200px; +} + +.weather-info-wrap { + min-width: 700px; + max-width: 700px; + min-height: 300px; + padding: 20px 0px; + text-align: center; + background-color: #eee; + border-radius: 10px; + border: 2px solid black; + box-shadow: 6px 6px 6px rgba(0,0,0,0.5); +} + +#weather-info { + display: none; + font-family: sans-serif; + padding: 20px 0; +} + +#location { + font-size: 30px; +} + +.primary-info { + display: flex; + justify-content: space-evenly; + margin: 20px; + font-size: 25px; +} + +.primary-info-left { + display: flex; + align-items: center; +} + +.primary-info-right { + display: flex; + justify-content: center; + align-items: center; + column-gap: 10px; +} + +.secondary-info { + margin: 40px 20px 0 20px; + display: flex; + flex-direction: column; + row-gap: 20px; +} + +.secondary-info-top { + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.secondary-info-bottom { + display: flex; + align-items: center; + justify-content: space-evenly; + margin-top: 40px; +} + +.secondary-info-bottom-left { + display: flex; + align-items: center; + flex-direction: column; + row-gap: 20px; + justify-content: space-evenly; +} + +.secondary-info-bottom-right { + width: 100px; + height: 100px; + border: 2px solid black; + border-radius: 50%; + position: relative; +} + +#compass-arrow { + width: 3px; + height: 60px; + position: absolute; + background-color: black; +} + +#compass-arrow { + width: 4px; + height: 70px; + top: 15%; + position: absolute; + background-color: black; +} + +.arrow-head { + width: 0; + height: 0; + border: 8px solid transparent; + border-left-color: black; + position: absolute; + top: -15%; + left: -6px; + transform: rotate(-90deg); +} + +.compass-direction { + position: absolute; + background-color: black; +} + +.north, .south { + width: 3px; + height: 8px; + left: 50%; +} + +.east, .west { + width: 8px; + height: 3px; + top: 50%; +} + +.north { + top: -8px; +} + +.south { + bottom: -8px; +} + +.east { + right: -8px; +} + +.west { + left: -8px; +} +``` + +```js +const getForecastBtn = document.getElementById('get-forecast'); +const selectEl = document.getElementById('location-selector'); + +const weatherInfoEl = document.getElementById('weather-info'); +const iconEl = document.getElementById('weather-icon'); +const tempEl = document.getElementById('main-temperature'); +const feelEl = document.getElementById('feels-like'); +const humidityEl = document.getElementById('humidity'); +const windEl = document.getElementById('wind'); +const gustEl = document.getElementById('wind-gust'); +const mainEl = document.getElementById('weather-main'); +const locationEl = document.getElementById('location'); +const arrowEl = document.getElementById('compass-arrow'); + +getForecastBtn.addEventListener('click', () => + selectEl.value && showWeather(selectEl.value) +); + +async function showWeather(city) { + const json = await getWeather(city); + const { + weather, + main: + { + temp, + feels_like, + humidity + }, + wind: { speed, gust, deg = 0 }, + name + } = json; + + const { main, icon } = weather[0]; + + weatherInfoEl.style.display = 'block'; + + iconEl.src = icon || ''; + tempEl.innerHTML = temp ? `${temp}° C` : 'N/A'; + feelEl.innerHTML = `Feels Like: ${feels_like ? `${feels_like}° C` : 'N/A'}`; + humidityEl.innerHTML = `Humidity: ${humidity ? `${humidity}%` : 'N/A'}`; + windEl.innerHTML = `Wind: ${speed ? `${speed} m/s` : 'N/A'}`; + gustEl.innerHTML = `Gusts: ${gust ? `${gust} m/s` : 'N/A'}`; + + mainEl.innerHTML = main || 'N/A'; + locationEl.innerHTML = name || 'N/A'; + arrowEl.style.transform = `rotate(${deg}deg)`; +} + +async function getWeather(city) { + try { + const response = await fetch(`https://weather-proxy.freecodecamp.rocks/api/city/${city}`) + + if (!response.ok) { + alert('Something went wrong, please try again later'); + } + + const json = await response.json(); + return json; + } catch (error) { + console.error(error.message); + } +} +``` diff --git a/curriculum/superblock-structure/full-stack.json b/curriculum/superblock-structure/full-stack.json index dc61defaa66..897c262ba64 100644 --- a/curriculum/superblock-structure/full-stack.json +++ b/curriculum/superblock-structure/full-stack.json @@ -523,6 +523,7 @@ "blocks": [ { "dashedName": "lecture-understanding-asynchronous-programming" }, { "dashedName": "workshop-fcc-authors-page" }, + { "dashedName": "lab-weather-app" }, { "dashedName": "review-asynchronous-javascript" }, { "dashedName": "quiz-asynchronous-javascript" } ]