diff --git a/README.md b/README.md index a121412..add6920 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ docker run --rm -p 3333:3333 forecast_client iframe.frameBorder = '0'; iframe.style.border = 'none'; iframe.style.width = '100%'; - iframe.style.minHeight = '450px'; + iframe.style.minHeight = '480px'; document.getElementById('weatherWidgetContainer').innerHTML = ''; document.getElementById('weatherWidgetContainer').appendChild(iframe); } @@ -47,11 +47,12 @@ docker run --rm -p 3333:3333 forecast_client - [X] ThemeProvider for themes customization - [X] The website is responsive and look nice on all screens - [X] The website works in all modern browsers -- [ ] Add info about humidity, clouds, precipitation +- [X] Responsible, adaptive design +- [X] Add info about wind, clouds, precipitation - [ ] Add weather icons - [ ] Unit tests - [X] City autocomplete requests are cached in browser (localStorage) -- [ ] Add compact mode to show in a round box +- [ ] Add compact mode to show daly forecast in a round box (separate component & URL) - ### Theme support `dark` (by default) and `light` themes supported diff --git a/examples/dark_theme.jpeg b/examples/dark_theme.jpeg index 4f42d51..bad32ee 100644 Binary files a/examples/dark_theme.jpeg and b/examples/dark_theme.jpeg differ diff --git a/examples/light_theme.jpeg b/examples/light_theme.jpeg index d63db68..057dde1 100644 Binary files a/examples/light_theme.jpeg and b/examples/light_theme.jpeg differ diff --git a/src/components/ForecastDaysSelector.tsx b/src/components/ForecastDaysSelector.tsx index fd15a43..39b42bf 100644 --- a/src/components/ForecastDaysSelector.tsx +++ b/src/components/ForecastDaysSelector.tsx @@ -2,19 +2,23 @@ import React from 'react'; import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ForecastDaysSelectorProps } from '../domain/types/ForecastDaysSelectorProps'; -export const ForecastDaysSelector: React.FC = ({ forecastDays, onForecastDaysChange }) => { +export const ForecastDaysSelector: React.FC = ({ forecastDays, onChange }) => { + const forecastPeriods = [3, 7, 14]; + return ( - 3 Days - 7 Days - 14 Days + {forecastPeriods.map((period) => ( + + {`${period} Days`} + + ))} ); }; diff --git a/src/components/Widget.tsx b/src/components/Widget.tsx index 83a768a..11631ff 100644 --- a/src/components/Widget.tsx +++ b/src/components/Widget.tsx @@ -1,14 +1,15 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { useQuery } from '@apollo/client'; import { useCityAutocomplete } from '../hooks/useCityAutocomplete'; import { GET_FORECAST_FROM_COORDS_QUERY } from '../graphql/queries'; import { SuggestedCity } from '../domain/types/SuggestedCity'; -import { useTheme, Autocomplete, Alert, TextField, CircularProgress } from "@mui/material"; -import { ResponsiveChartContainer, LinePlot, ChartsXAxis, ChartsYAxis, ChartsLegend, ChartsGrid, ChartsReferenceLine, ChartsTooltip } from '@mui/x-charts'; +import { useTheme, Autocomplete, TextField, CircularProgress, Grid } from "@mui/material"; +import { ResponsiveChartContainer, LinePlot, ChartsXAxis, ChartsYAxis, ChartsLegend, ChartsGrid, ChartsReferenceLine, ChartsTooltip, BarPlot } from '@mui/x-charts'; import dayjs from 'dayjs'; import { WeatherData } from './../domain/types/WeatherData'; import { ForecastDaysSelector } from './ForecastDaysSelector'; import { DEFAULT_WIDGET_PARAMS } from '../infrastructure/constants'; +import { renderErrorAlert } from './../utils/renderErrorAlert'; export const Widget: React.FC = () => { const [forecastDays, setForecastDays] = useState(DEFAULT_WIDGET_PARAMS.days); @@ -18,9 +19,8 @@ export const Widget: React.FC = () => { days: forecastDays }); const { palette } = useTheme(); - console.log('palette', palette); - const { loading: loadingForecast, error: errorForecast, data } = useQuery(GET_FORECAST_FROM_COORDS_QUERY, { + const { loading: loadingForecast, error: errorForecast, data: forecastData } = useQuery(GET_FORECAST_FROM_COORDS_QUERY, { variables: queryParams }); @@ -53,83 +53,152 @@ export const Widget: React.FC = () => { })); }; - const [chartData, setChartData] = useState({ + type ChartData = { + xAxisData: Date[]; + seriesData: number[][]; + seriesLabels?: string[]; + }; + + const [chartData, setChartData] = useState({ xAxisData: [], - seriesData: [] as number[][] + seriesData: [], }); - useMemo(() => { - if (data && data.getForecastByCoordinates) { - const timestamps = data.getForecastByCoordinates.map((forecast: WeatherData) => new Date(forecast.timestamp)); - const temperatures = data.getForecastByCoordinates.map((forecast: WeatherData) => forecast.temperature); - setChartData({ - xAxisData: timestamps, - seriesData: [temperatures] + useEffect(() => { + if (forecastData && forecastData.getForecastByCoordinates) { + const chartData = forecastData.getForecastByCoordinates.reduce((data: ChartData, forecast: WeatherData) => { + data.xAxisData.push(new Date(forecast.timestamp)); + data.seriesData[0].push(forecast.temperature); + data.seriesData[1].push(forecast.precipitation); + data.seriesData[2].push(forecast.cloudCover); + data.seriesData[3].push(forecast.windSpeed); + return data; + }, { + xAxisData: [], + seriesData: [[], [], [], []], + seriesLabels: ['Temperature, C', 'Precipitation, mm', 'Cloud Cover', 'Wind Speed, km/h'], }); + + setChartData(chartData); } - }, [data]); + }, [forecastData]); return (
- { - setInputValue(newInputValue); - }} - getOptionLabel={(option) => option.name + ', ' + option.country} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderInput={(params) => ( - - )} - style={{ marginBottom: '1rem', paddingTop: '1rem' }} - /> + + + { + setInputValue(newInputValue); + }} + getOptionLabel={(option) => option.name + ', ' + option.country} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => ( + + )} + style={{ width: 300 }} + /> + + + + + + {(loadingCities || loadingForecast) && (
)} - {errorCities && ( - - Error loading cities: {errorCities.message} - - )} - {errorForecast && ( - - Error loading forecast: {errorForecast.message} - - )} - - + {errorCities && renderErrorAlert(errorCities, 'cities')} + {errorForecast && renderErrorAlert(errorForecast, 'forecast')} - {data && ( + {forecastData && chartData.seriesLabels && ( dayjs(date).format("MMM D, hA"), + }, + { + id: 'topAxis', + label: "Cloud Cover %", + data: chartData.seriesData[2], + scaleType: 'point' }]} - yAxis={[{ label: "Temperature (°C)" }]} - series={[{ type: 'line', label: selectedCity?.name || 'City', data: chartData.seriesData[0] }]} - height={300} + yAxis={[ + { id: 'leftAxis', label: "Temperature, °C", scaleType: 'linear' }, + { + id: 'rightAxis', + label: "Wind speed, km/h", + data: chartData.seriesData[3], + scaleType: 'linear', + } + ]} + series={[ + { type: 'line', label: chartData.seriesLabels![0], data: chartData.seriesData[0], color: palette.chart.tempterature }, + { type: 'bar', label: chartData.seriesLabels![1], data: chartData.seriesData[1], color: palette.chart.precipitation }, + { type: 'band', label: chartData.seriesLabels![2], data: chartData.seriesData[2], xAxisKey: 'topAxis' }, + { type: 'line', label: chartData.seriesLabels![3], data: chartData.seriesData[3], yAxisKey: 'rightAxis', color: palette.chart.wind }, + ]} + height={400} + margin={{ top: 120 }} + sx={{ + "& .MuiChartsAxis-left .MuiChartsAxis-label": { + strokeWidth: "0.4", + fill: `${palette.chart.tempterature}`, + }, + "& .MuiChartsAxis-left line": { + stroke: `${palette.chart.tempterature}`, + }, + "& .MuiChartsAxis-left text": { + stroke: `${palette.chart.tempterature}`, + }, + // right Axis styles + "& .MuiChartsAxis-right .MuiChartsAxis-label": { + strokeWidth: "0.4", + fill: `${palette.chart.wind}`, + }, + "& .MuiChartsAxis-right line": { + stroke: `${palette.chart.wind}`, + }, + "& .MuiChartsAxis-right text": { + stroke: `${palette.chart.wind}`, + }, + // top Axis styles + "& .MuiChartsAxis-top .MuiChartsAxis-label": { + strokeWidth: "0.4", + fill: `${palette.chart.clouds}`, + }, + "& .MuiChartsAxis-top line": { + stroke: `${palette.chart.clounds}`, + }, + "& .MuiChartsAxis-top text": { + stroke: `${palette.chart.clouds}`, + strokeWidth: "0.6", + }, + }} > - - + + + + - - {/* Can not fix Grid key warnings so far */} + + diff --git a/src/domain/types/ForecastDaysSelectorProps.ts b/src/domain/types/ForecastDaysSelectorProps.ts index 78e12df..51e3d7a 100644 --- a/src/domain/types/ForecastDaysSelectorProps.ts +++ b/src/domain/types/ForecastDaysSelectorProps.ts @@ -1,4 +1,4 @@ export type ForecastDaysSelectorProps = { forecastDays: number; - onForecastDaysChange: (_event: React.MouseEvent, value: number) => void; + onChange: (_event: React.MouseEvent, value: number) => void; } diff --git a/src/domain/types/WeatherData.ts b/src/domain/types/WeatherData.ts index 7d3da87..06af843 100644 --- a/src/domain/types/WeatherData.ts +++ b/src/domain/types/WeatherData.ts @@ -9,5 +9,7 @@ export type WeatherData = { windSpeed: number; cloudCover: number; sunshineDuration: number; + precipitationProbability: number; + precipitation: number; temperatureUnit: string; }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 5348b75..b411e8f 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -13,6 +13,8 @@ export const GET_FORECAST_FROM_COORDS_QUERY = gql` windSpeed cloudCover sunshineDuration + precipitationProbability + precipitation temperatureUnit } } diff --git a/src/infrastructure/providers/ThemeProvider.tsx b/src/infrastructure/providers/ThemeProvider.tsx index fef40a7..2a6a665 100644 --- a/src/infrastructure/providers/ThemeProvider.tsx +++ b/src/infrastructure/providers/ThemeProvider.tsx @@ -3,10 +3,15 @@ import { useLocation } from 'react-router-dom'; import { ThemeProvider as MUIThemeProvider } from '@mui/material/styles'; import { createTheme, PaletteMode } from "@mui/material"; +enum Themes { + Dark = 'dark', + Light = 'light' +} + export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const location = useLocation(); const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); - const getThemeMode = (themeParam: string | null) => themeParam === 'light' ? 'light' : 'dark'; + const getThemeMode = (themeParam: string | null) => themeParam === Themes.Light ? Themes.Light : Themes.Dark; const mode: PaletteMode = getThemeMode(searchParams.get('theme')); const themeInit = (mode: PaletteMode) => createTheme({ palette: { @@ -20,6 +25,12 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre primary: '#31363F', secondary: 'rgba(49, 54, 63, 0.7)', }, + chart: { + tempterature: '#e15759', + precipitation: '#76b7b2', + wind: '#ff7f00', + clouds: '#4e79a7' + }, } : { background: { default: '#31363F', @@ -29,6 +40,12 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre primary: '#EEEEEE', secondary: 'rgba(255, 255, 255, 0.7)', }, + chart: { + tempterature: '#e15759', + precipitation: '#4e79a7', + wind: '#edc949', + clouds: '#76b7b2' + }, }) }, }); diff --git a/src/utils/renderErrorAlert.tsx b/src/utils/renderErrorAlert.tsx new file mode 100644 index 0000000..46a622d --- /dev/null +++ b/src/utils/renderErrorAlert.tsx @@ -0,0 +1,9 @@ +import { Alert } from "@mui/material"; + +export const renderErrorAlert = (error: Error | undefined, context: string) => ( + error && ( + + Error loading { context }: { error.message } + + ) +);