Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
be11a24
install esLint package, TS, and RTK
cmdarcy Mar 7, 2025
3d1344a
remove boilerplate
cmdarcy Mar 9, 2025
63ff9c2
fix linting issue
cmdarcy Mar 10, 2025
ce093e6
create initial SearchForm component
cmdarcy Mar 10, 2025
810bf99
configure inital store
cmdarcy Mar 10, 2025
3c0360f
create ProviderClient to provide store
cmdarcy Mar 10, 2025
ae221ee
create initial state for store, add initial fetchForecast func
cmdarcy Mar 10, 2025
00e5ef0
add types and error handling to slice
cmdarcy Mar 11, 2025
ef32d21
add types to store and dispatch and selector funcs
cmdarcy Mar 11, 2025
2000dd1
add initial sparkline functionality
cmdarcy Mar 11, 2025
f7013a2
update types throughout
cmdarcy Mar 11, 2025
57c187f
add env, fix fetchForecast bug
cmdarcy Mar 12, 2025
e956bc4
add basic form validation
cmdarcy Mar 12, 2025
c0a2255
improve chart component functionality
cmdarcy Mar 12, 2025
f3cd31d
move forecast to separate component
cmdarcy Mar 12, 2025
2f5bfab
remove initial styling, install tailwind
cmdarcy Mar 12, 2025
d19a62f
install shadcn
cmdarcy Mar 13, 2025
e84b2ec
fix next error in initial layout
cmdarcy Mar 13, 2025
871c9eb
fix typo
cmdarcy Mar 13, 2025
a172eb2
add shadcn chart and intial style
cmdarcy Mar 13, 2025
9751683
add styling
cmdarcy Mar 13, 2025
90e007e
replace form component with shad form, change theme colors
cmdarcy Mar 13, 2025
f3c117e
add error alert component
cmdarcy Mar 13, 2025
b2ff2c6
format tooltip in shadChart
cmdarcy Mar 14, 2025
ac177bc
fix formatting shadchart, refactor forecast
cmdarcy Mar 14, 2025
f4bf0cd
extract types to separate file
cmdarcy Mar 14, 2025
359219c
clean up old components/pages
cmdarcy Mar 14, 2025
82255a2
add dark mode functionality
cmdarcy Mar 14, 2025
bd38783
add JSDoc comments
cmdarcy Mar 19, 2025
6c59e2c
style theme toggle
cmdarcy Mar 19, 2025
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
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
"extends": ["wesbos/typescript"]
}
5 changes: 5 additions & 0 deletions app/Store/hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
7 changes: 7 additions & 0 deletions app/Store/rootReducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { combineReducers } from '@reduxjs/toolkit';

import forecastReducer from './slices/forecastSlice';

export const rootReducer = combineReducers({
forecast: forecastReducer,
});
75 changes: 75 additions & 0 deletions app/Store/slices/forecastSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { RootState } from '../store';
import { WeatherData } from '@/types/forecastTypes';

const APIKEY = process.env.NEXT_PUBLIC_OPEN_WEATHER_APIKEY;

/**
* Fetches the weather forecast based on the provided search term.
* @param {string} searchTerm - The term to search for the forecast.
* @returns {Promise<WeatherData>} The weather forecast data.
* @throws {Error} If the fetch fails or the response is not ok.
*/
export const fetchForecast = createAsyncThunk(
'forecast/fetchForecast',
async (searchTerm: string): Promise<WeatherData> => {
try {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/forecast?q=${searchTerm}&appid=${APIKEY}&units=imperial`,
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = (await response.json()) as WeatherData;
return data;
} catch (error) {
if (error instanceof Error) {
console.error('Fetch error:', error.message);
} else {
console.error('Unknown error:', error);
}
throw error;
}
},
);

type ForecastState = {
forecast: WeatherData | null;
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
};

const initialState: ForecastState = {
forecast: null,
status: 'idle',
error: null,
};

const forecastSlice = createSlice({
name: 'forecast',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchForecast.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchForecast.fulfilled, (state, action) => {
state.status = 'succeeded';
state.forecast = action.payload;
})
.addCase(fetchForecast.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});

/**
* Selects the forecast from the state.
* @param {RootState} state - The root state.
* @returns {WeatherData | null} The forecast data.
*/
export const selectForecast = (state: RootState) => state.forecast;

export default forecastSlice.reducer;
9 changes: 9 additions & 0 deletions app/Store/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from './rootReducer';

export const store = configureStore({
reducer: rootReducer,
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
51 changes: 51 additions & 0 deletions app/components/Forecast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { selectForecast } from '../Store/slices/forecastSlice';
import { useAppSelector } from '../Store/hook';
import ShadChart from './ShadChart';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';

/**
* Displays the weather forecast based on the selected city.
* Utilizes the Redux store to retrieve forecast data.
* @returns {JSX.Element} The rendered forecast component.
*/
function Forecast() {
const { forecast, status, error } = useAppSelector(selectForecast);
let city: string;

if (status === 'succeeded') {
city = forecast.city.name;
}

return (
<div className="mt-7">
{status === 'loading' && <p>Loading...</p>}
{status === 'failed' && (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>{error}</AlertTitle>
<AlertDescription>
Sorry there was an error, please try searching again!
</AlertDescription>
</Alert>
)}
{status === 'succeeded' && (
<div className="w-screen px-24">
<h2 className=" text-center scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0">
{city}
</h2>
<div className="flex flex-col gap-12">
<ShadChart dataType="temp" forecastData={forecast.list} />
<ShadChart dataType="pressure" forecastData={forecast.list} />
<ShadChart dataType="humidity" forecastData={forecast.list} />
</div>
</div>
)}
</div>
);
}

export default Forecast;
45 changes: 45 additions & 0 deletions app/components/ModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';

import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

/**
* A button to toggle between light and dark themes.
* Utilizes the Next.js theme provider.
* @returns {JSX.Element} The rendered button component.
*/
export function ModeToggle() {
const { setTheme } = useTheme();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
20 changes: 20 additions & 0 deletions app/components/ProviderClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use client';

import React, { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { store } from '../Store/store';

type ProviderClientProps = {
children: ReactNode[] | ReactNode;
};

/**
* A provider component that wraps children in a Redux Provider.
* @param {ProviderClientProps} props - The props for the provider component.
* @returns {JSX.Element} The rendered provider component.
*/
function ProviderClient({ children }: ProviderClientProps) {
return <Provider store={store}> {children} </Provider>;
}

export default ProviderClient;
157 changes: 157 additions & 0 deletions app/components/ShadChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
'use client';

import React from 'react';
import { CartesianGrid, Line, LineChart, ReferenceLine } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { ForecastDataPoint } from '@/types/forecastTypes';

const options: Intl.DateTimeFormatOptions = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
hour12: true,
};

const formatter = new Intl.DateTimeFormat('en-US', options);

const chartConfig = {
temp: {
label: 'Temperature(℉)',
color: '#2563eb',
},
pressure: {
label: 'Pressure(hPa) ',
},
humidity: {
label: 'Humidity(%)',
},
} satisfies ChartConfig;

type ShadChartProps = {
forecastData: ForecastDataPoint[];
dataType: 'temp' | 'pressure' | 'humidity';
};

/**
* Renders a chart displaying forecast data for temperature, pressure, or humidity.
* @param {ShadChartProps} props - The properties for the chart component.
* @returns {JSX.Element} The rendered chart component.
*/
function ShadChart({ forecastData, dataType }: ShadChartProps) {
const tempForecasts = forecastData.map(
(foreCastEntry: ForecastDataPoint) => foreCastEntry.main.temp,
);
const pressureForecasts = forecastData.map(
(foreCastEntry: ForecastDataPoint) => foreCastEntry.main.pressure,
);
const humidityForecasts = forecastData.map(
(foreCastEntry: ForecastDataPoint) => foreCastEntry.main.humidity,
);

const tempAverage = Math.round(
tempForecasts.reduce((prev, curr) => prev + curr) / tempForecasts.length,
);
const pressureAverage = Math.round(
pressureForecasts.reduce((prev, curr) => prev + curr) /
pressureForecasts.length,
);
const humidityAverage = Math.round(
humidityForecasts.reduce((prev, curr) => prev + curr) /
humidityForecasts.length,
);

let averageLineValue: number;
let displayAverage: string;
if (dataType === 'temp') {
averageLineValue = tempAverage;
displayAverage = `${tempAverage} ℉`;
} else if (dataType === 'humidity') {
averageLineValue = humidityAverage;
displayAverage = `${humidityAverage} % `;
} else {
averageLineValue = pressureAverage;
displayAverage = `${pressureAverage} hPA`;
}

const chartData = forecastData.map((f) => {
const date = new Date(f.dt_txt);
const displayDate = formatter.format(date);
return {
temp: f.main.temp,
pressure: f.main.pressure,
humidity: f.main.humidity,
displayDate,
};
});
return (
<Card>
<CardHeader>
<CardTitle>{dataType.toUpperCase()}</CardTitle>
<CardDescription>5 Day Forecast</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
formatter={(value, name, item, index, payload) =>
`${value} at ${payload.displayDate}`
}
/>
}
/>
<ReferenceLine
y={averageLineValue}
label="Avg"
stroke="red"
strokeDasharray="3 3"
/>
<Line
dataKey={dataType}
type="natural"
stroke="var(--color-temp)"
strokeWidth={2}
dot={{
fill: 'var(--color-temp)',
}}
activeDot={{
r: 6,
}}
/>
</LineChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="leading-none text-muted-foreground">
{dataType.toUpperCase()} Average: {displayAverage}
</div>
</CardFooter>
</Card>
);
}

export default ShadChart;
Loading