Skip to content

Commit

Permalink
Add WeatherCards component for daily forecast
Browse files Browse the repository at this point in the history
  • Loading branch information
lappi-lynx committed Apr 3, 2024
1 parent 6ef8b13 commit d3cc921
Show file tree
Hide file tree
Showing 17 changed files with 213 additions and 23 deletions.
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ docker run --rm -p 3333:3333 forecast_client
function injectWeatherWidget(theme = 'dark') {
var iframe = document.createElement('iframe');
// client app url
iframe.src = `http://localhost:3333/forecast?theme=${theme}`;
iframe.src = `http://localhost:3333/hourly_forecast?theme=${theme}`;
iframe.width = '100%';
iframe.height = '100%';
iframe.frameBorder = '0';
Expand Down Expand Up @@ -49,12 +49,20 @@ docker run --rm -p 3333:3333 forecast_client
- [X] The website works in all modern browsers
- [X] Responsible, adaptive design
- [X] Add info about wind, clouds, precipitation
- [ ] Add weather icons
- [X] Add weather icons
- [ ] Unit tests
- [X] City autocomplete requests are cached in browser (localStorage)
- [ ] Add compact mode to show daly forecast in a round box (separate component & URL)
-
- [X] Add separate daily mode to show forecast in a round boxes (separate component & URL)


### Theme support
2 modes available: `daily` and `hourly`.
#### Hourly mode examples:
`dark` (by default) and `light` themes supported
![Dark theme](./examples/dark_theme.jpeg)
![Light theme](./examples/light_theme.jpeg)
#### Daily mode examples:
![Dark theme long](./examples/daily_dark_long.jpeg)
![Light theme long](./examples/daily_light_long.jpeg)
![Dark theme short](./examples/daily_dark_short.jpeg)
![Light theme short](./examples/daily_light_short.jpeg)
Binary file added examples/daily_dark_long.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/daily_dark_short.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/daily_light_long.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/daily_light_short.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"graphql": "^16.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { ApolloProvider } from '@apollo/client';
import { client } from './graphql/client';
import { Widget } from './components/Widget';
import { ThemeProvider } from './infrastructure/providers/ThemeProvider';
import { ForecastModeEnum } from './domain/types/WidgetProps';

function App() {
return (
<ApolloProvider client={client}>
<Router>
<ThemeProvider>
<Routes>
<Route path="/forecast" element={<Widget />} />
<Route path="/daily_forecast" element={<Widget mode={ ForecastModeEnum.DAILY } />} />
<Route path="/hourly_forecast" element={<Widget mode={ ForecastModeEnum.HOURLY } />} />
</Routes>
</ThemeProvider>
</Router>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import { ForecastDaysSelectorProps } from '../../domain/types/ForecastDaysSelectorProps';
import { ForecastDaysSelectorProps } from '../domain/types/ForecastDaysSelectorProps';

export const ForecastDaysSelector: React.FC<ForecastDaysSelectorProps> = ({ forecastDays, onChange }) => {
const forecastPeriods = [3, 7, 14];
Expand Down
22 changes: 15 additions & 7 deletions src/components/Widget.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { useCityAutocomplete } from '../hooks/useCityAutocomplete';
import { GET_FORECAST_FROM_COORDS_QUERY } from '../graphql/queries';
import { GET_HOURLY_FORECAST_FROM_COORDS_QUERY, GET_DAILY_FORECAST_FROM_COORDS_QUERY } from '../graphql/queries';
import { SuggestedCity } from '../domain/types/SuggestedCity';
import { useTheme, Autocomplete, TextField, CircularProgress, Grid } from "@mui/material";
import { ForecastDaysSelector } from './chart/ForecastDaysSelector';
import { WeatherChart } from './chart/WeatherChart';
import { ForecastDaysSelector } from './ForecastDaysSelector';
import { WeatherChart } from './hourly/WeatherChart';
import { DEFAULT_WIDGET_PARAMS } from '../infrastructure/constants';
import { renderErrorAlert } from './../utils/renderErrorAlert';
import { WidgetProps, ForecastModeEnum } from '../domain/types/WidgetProps';
import { WeatherCards } from './daily/WeatherCards';

export const Widget: React.FC = () => {
export const Widget: React.FC<WidgetProps> = ({ mode }) => {
const [forecastDays, setForecastDays] = useState(DEFAULT_WIDGET_PARAMS.days);
const [queryParams, setQueryParams] = useState({
latitude: DEFAULT_WIDGET_PARAMS.latitude,
Expand All @@ -18,7 +20,9 @@ export const Widget: React.FC = () => {
});
const { palette } = useTheme();

const { loading: loadingForecast, error: errorForecast, data: forecastData } = useQuery(GET_FORECAST_FROM_COORDS_QUERY, {
const forecastQuery = mode === ForecastModeEnum.DAILY ? GET_DAILY_FORECAST_FROM_COORDS_QUERY : GET_HOURLY_FORECAST_FROM_COORDS_QUERY;

const { loading: loadingForecast, error: errorForecast, data: forecastData } = useQuery(forecastQuery, {
variables: queryParams
});

Expand Down Expand Up @@ -88,8 +92,12 @@ export const Widget: React.FC = () => {
{errorCities && renderErrorAlert(errorCities, 'cities')}
{errorForecast && renderErrorAlert(errorForecast, 'forecast')}

{forecastData && (
<WeatherChart forecastData={forecastData.getForecastByCoordinates} palette={palette} />
{(forecastData && mode === ForecastModeEnum.DAILY) && (
<WeatherCards forecastData={forecastData.getDailyForecastByCoordinates} palette={palette} />
)}

{(forecastData && mode === ForecastModeEnum.HOURLY) && (
<WeatherChart forecastData={forecastData.getHourlyForecastByCoordinates} palette={palette} />
)}
</main>
);
Expand Down
59 changes: 59 additions & 0 deletions src/components/daily/WeatherCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { DailyWeatherData } from '../../domain/types/WeatherData';
import Typography from '@mui/material/Typography';
import { Box } from '@mui/system';
import { Theme, Grid } from '@mui/material';
import { weatherCodeToIcon } from '../../utils/weatherIconMapping';

export const WeatherCards: React.FC<{ forecastData: DailyWeatherData[]; palette: Theme['palette'] }> = ({ forecastData, palette }) => {
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' };
return date.toLocaleDateString('en-EN', options);
};

const formatSunshineDuration = (seconds: number) => {
const hours = seconds / 3600;
return hours.toFixed(2);
};

return (
<Box mt={3} sx={{ overflowX: 'auto', display: 'flex', justifyContent: 'space-around', '&::-webkit-scrollbar': { display: 'none' }}}>
<Grid container pb={1} spacing={0} sx={{ width: 'auto', flexWrap: 'nowrap', justifyContent: 'stretch', height: 220 }}>
{forecastData.map((forecast, index) => {
const { Icon, color } = weatherCodeToIcon(forecast.weatherCode, palette.icon);
return (
<Grid item key={index} sx={{ minWidth: 160, flexShrink: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'left', borderLeft: '1px solid', borderColor: palette.background.box }}>
<Box sx={{ display: 'flex', alignItems: 'left', justifyContent: 'stretch', textAlign: 'left', p: 1, backgroundColor: palette.background.box, borderRadius: 2 }}>
<Icon size={56} color={color} />
<Typography variant="subtitle2" sx={{ mb: 1, ml: 1 }}>
{formatDate(forecast.timestamp)}
</Typography>
</Box>
<Box pl={2}>
<Typography variant="h6" sx={{ mt: 1 }}>
{forecast.temperatureMax}°C / {forecast.temperatureMin}°C
</Typography>
<Typography variant="subtitle1">
{forecast.precipitationProbability}% rain
</Typography>
<Typography variant="body2">
<Typography variant="caption">Wind: </Typography>
{forecast.windSpeed} km/h
</Typography>
<Typography variant="body2">
<Typography variant="caption">Sunshine: </Typography>
{formatSunshineDuration(forecast.sunshineDuration)} hrs
</Typography>
<Typography variant="body2">
<Typography variant="caption">Precip: </Typography>
{forecast.precipitation} mm
</Typography>
</Box>
</Grid>
);
})}
</Grid>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import React, { useState, useEffect } from 'react';
import { ResponsiveChartContainer, LinePlot, ChartsXAxis, ChartsYAxis, ChartsLegend, ChartsGrid, ChartsReferenceLine, ChartsTooltip, BarPlot } from '@mui/x-charts';
import { WeatherData } from './../../domain/types/WeatherData';
import { HourlyWeatherData } from '../../domain/types/WeatherData';
import { Theme } from '@mui/material';
import { formatDateForChart } from './../../utils/formatDateForWidget';
import { ChartData } from './../../domain/types/ChartData';
import { formatDateForChart } from '../../utils/formatDateForWidget';
import { ChartData } from '../../domain/types/ChartData';

export const WeatherChart: React.FC<{ forecastData: WeatherData[]; palette: Theme['palette'] }> = ({ forecastData, palette }) => {
export const WeatherChart: React.FC<{ forecastData: HourlyWeatherData[]; palette: Theme['palette'] }> = ({ forecastData, palette }) => {
const [chartData, setChartData] = useState<ChartData>({
xAxisData: [],
seriesData: [],
});

useEffect(() => {
if (forecastData) {
const chartData = forecastData.reduce((data: ChartData, forecast: WeatherData) => {
const chartData = forecastData.reduce((data: ChartData, forecast: HourlyWeatherData) => {
data.xAxisData.push(new Date(forecast.timestamp));
data.seriesData[0].push(forecast.temperature);
data.seriesData[1].push(forecast.precipitation);
Expand Down
18 changes: 17 additions & 1 deletion src/domain/types/WeatherData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type WeatherData = {
export type HourlyWeatherData = {
location: {
latitude: number;
longitude: number;
Expand All @@ -13,3 +13,19 @@ export type WeatherData = {
precipitation: number;
temperatureUnit: string;
};

export type DailyWeatherData = {
location: {
latitude: number;
longitude: number;
};
timestamp: string;
temperatureMax: number;
temperatureMin: number;
weatherCode: number;
windSpeed: number;
sunshineDuration: number;
precipitationProbability: number;
precipitation: number;
temperatureUnit: string;
};
7 changes: 6 additions & 1 deletion src/domain/types/WidgetProps.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export enum ForecastModeEnum {
DAILY = 'daily',
HOURLY = 'hourly',
}

export type WidgetProps = {
theme: string;
mode: ForecastModeEnum;
}
26 changes: 23 additions & 3 deletions src/graphql/queries.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client';

export const GET_FORECAST_FROM_COORDS_QUERY = gql`
query GetWeather($latitude: Float!, $longitude: Float!, $days: Int) {
getForecastByCoordinates(latitude: $latitude, longitude: $longitude, days: $days) {
export const GET_HOURLY_FORECAST_FROM_COORDS_QUERY = gql`
query HourlyWeatherData($latitude: Float!, $longitude: Float!, $days: Int) {
getHourlyForecastByCoordinates(latitude: $latitude, longitude: $longitude, days: $days) {
location {
latitude
longitude
Expand All @@ -19,3 +19,23 @@ export const GET_FORECAST_FROM_COORDS_QUERY = gql`
}
}
`;

export const GET_DAILY_FORECAST_FROM_COORDS_QUERY = gql`
query DailyWeatherData($latitude: Float!, $longitude: Float!, $days: Int) {
getDailyForecastByCoordinates(latitude: $latitude, longitude: $longitude, days: $days) {
location {
latitude
longitude
}
timestamp
temperatureMax
temperatureMin
weatherCode
windSpeed
sunshineDuration
precipitationProbability
precipitation
temperatureUnit
}
}
`;
14 changes: 14 additions & 0 deletions src/infrastructure/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
background: {
default: '#EEEEEE',
paper: '#EEEEEE',
box: '#b0bec5',
},
text: {
primary: '#31363F',
Expand All @@ -31,10 +32,17 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
wind: '#ff7f00',
clouds: '#4e79a7'
},
icon: {
sun: '#d84315',
rain: '#0277bd',
cloud: '#757575',
snow: '#fff',
},
} : {
background: {
default: '#31363F',
paper: '#31363F',
box: '#607d8b',
},
text: {
primary: '#EEEEEE',
Expand All @@ -46,6 +54,12 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
wind: '#edc949',
clouds: '#76b7b2'
},
icon: {
sun: '#ffa000',
rain: '#00bcd4',
cloud: '#9e9e9e',
snow: '#e0e0e0',
},
})
},
});
Expand Down
48 changes: 48 additions & 0 deletions src/utils/weatherIconMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { IconType } from 'react-icons';
import { WiDaySunny, WiDayCloudy, WiCloud, WiFog, WiSprinkle, WiRain, WiRainMix, WiSnow, WiSnowflakeCold, WiThunderstorm } from 'react-icons/wi';

type ColorPalette = {
sun: string;
rain: string;
cloud: string;
snow: string;
}

type WeatherIcon = {
Icon: IconType;
color: string;
}

export const weatherCodeToIcon = (code: number, color_palette: ColorPalette): WeatherIcon => {
switch (code) {
case 0: return { Icon: WiDaySunny, color: color_palette.sun };
case 1:
case 2:
case 3: return { Icon: WiDayCloudy, color: color_palette.sun };
case 45:
case 48: return { Icon: WiFog, color: color_palette.cloud };
case 51:
case 53:
case 55: return { Icon: WiSprinkle, color: color_palette.rain };
case 56:
case 57: return { Icon: WiRainMix, color: color_palette.rain }; // Freezing Drizzle
case 61:
case 63:
case 65: return { Icon: WiRain, color: color_palette.rain }; // Rain
case 66:
case 67: return { Icon: WiRainMix, color: color_palette.rain }; // Freezing Rain
case 71:
case 73:
case 75: return { Icon: WiSnow, color: color_palette.snow };
case 77: return { Icon: WiSnowflakeCold, color: color_palette.snow };
case 80:
case 81:
case 82: return { Icon: WiRain, color: color_palette.rain };
case 85:
case 86: return { Icon: WiSnow, color: color_palette.snow };
case 95:
case 96:
case 99: return { Icon: WiThunderstorm, color: color_palette.rain };
default: return { Icon: WiCloud, color: color_palette.cloud };
}
};

0 comments on commit d3cc921

Please sign in to comment.