mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 10:16:47 +00:00
refactor: Update weather components to use new forecast types and improve data handling
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
FiList,
|
||||
} from "react-icons/fi"
|
||||
|
||||
import { AreaMetadata, Forecast, ForecastPeriod } from "@/client/types.gen"
|
||||
import { AreaMetadata, ForecastInfo, ForecastPeriodGeneral } from "@/client/types.gen"
|
||||
import WeatherMap from "./WeatherMap"
|
||||
|
||||
// Map forecast descriptions to icons
|
||||
@@ -48,30 +48,30 @@ const forecastIcons: Record<string, React.ElementType> = {
|
||||
}
|
||||
|
||||
interface ForecastCardProps {
|
||||
forecast: Forecast
|
||||
forecast: ForecastInfo
|
||||
}
|
||||
|
||||
const ForecastCard: React.FC<ForecastCardProps> = ({ forecast }) => {
|
||||
const WeatherIcon = forecastIcons[forecast.forecast] || FiCloud
|
||||
const WeatherIcon = forecastIcons[forecast.text] || FiCloud
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
height="100%"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Box pb={2}>
|
||||
<Heading size="sm" truncate>
|
||||
{forecast.area}
|
||||
{forecast.code}
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box>
|
||||
<Flex direction="column" alignItems="center">
|
||||
<Icon as={WeatherIcon} boxSize={8} mb={2} />
|
||||
<Text fontSize="sm" textAlign="center" title={forecast.forecast}>
|
||||
{forecast.forecast}
|
||||
<Text fontSize="sm" textAlign="center" title={forecast.text}>
|
||||
{forecast.text}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
@@ -80,7 +80,7 @@ const ForecastCard: React.FC<ForecastCardProps> = ({ forecast }) => {
|
||||
}
|
||||
|
||||
interface ForecastPeriodDisplayProps {
|
||||
validPeriod: ForecastPeriod
|
||||
validPeriod: ForecastPeriodGeneral
|
||||
}
|
||||
|
||||
const ForecastPeriodDisplay: React.FC<ForecastPeriodDisplayProps> = ({
|
||||
@@ -112,8 +112,8 @@ const ForecastPeriodDisplay: React.FC<ForecastPeriodDisplayProps> = ({
|
||||
}
|
||||
|
||||
interface WeatherForecastProps {
|
||||
forecasts: Forecast[]
|
||||
validPeriod: ForecastPeriod
|
||||
forecasts: ForecastInfo[]
|
||||
validPeriod: ForecastPeriodGeneral
|
||||
areaMetadata: AreaMetadata[]
|
||||
}
|
||||
|
||||
@@ -123,14 +123,14 @@ const WeatherForecast: React.FC<WeatherForecastProps> = ({
|
||||
areaMetadata,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'map'>('grid');
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<ForecastPeriodDisplay validPeriod={validPeriod} />
|
||||
|
||||
|
||||
<Flex justifyContent="center" mb={4}>
|
||||
<ButtonGroup attached variant="outline" size="sm">
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setViewMode('grid')}
|
||||
colorScheme={viewMode === 'grid' ? "blue" : undefined}
|
||||
>
|
||||
@@ -139,7 +139,7 @@ const WeatherForecast: React.FC<WeatherForecastProps> = ({
|
||||
<Text>Grid View</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setViewMode('map')}
|
||||
colorScheme={viewMode === 'map' ? "blue" : undefined}
|
||||
>
|
||||
@@ -150,7 +150,7 @@ const WeatherForecast: React.FC<WeatherForecastProps> = ({
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<Grid
|
||||
templateColumns={{
|
||||
@@ -163,16 +163,16 @@ const WeatherForecast: React.FC<WeatherForecastProps> = ({
|
||||
gap={4}
|
||||
>
|
||||
{forecasts.map((forecast, index) => (
|
||||
<ForecastCard key={`${forecast.area}-${index}`} forecast={forecast} />
|
||||
<ForecastCard key={`${forecast.code}-${index}`} forecast={forecast} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
|
||||
{viewMode === 'map' && (
|
||||
<WeatherMap
|
||||
forecasts={forecasts}
|
||||
validPeriod={validPeriod}
|
||||
areaMetadata={areaMetadata}
|
||||
<WeatherMap
|
||||
forecasts={forecasts}
|
||||
validPeriod={validPeriod}
|
||||
areaMetadata={areaMetadata}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
FiWind,
|
||||
} from "react-icons/fi"
|
||||
|
||||
import { Forecast, ForecastPeriod, AreaMetadata } from "@/client/types.gen"
|
||||
import { ForecastInfo, ForecastPeriodGeneral, AreaMetadata } from "@/client/types.gen"
|
||||
|
||||
// Fix for default markers not showing
|
||||
// In a real app, you'd want to use proper image assets
|
||||
@@ -60,19 +60,19 @@ const MapCenter = ({ center }: { center: [number, number] }) => {
|
||||
};
|
||||
|
||||
interface WeatherMapProps {
|
||||
forecasts: Forecast[];
|
||||
validPeriod: ForecastPeriod;
|
||||
forecasts: ForecastInfo[];
|
||||
validPeriod: ForecastPeriodGeneral;
|
||||
areaMetadata: AreaMetadata[];
|
||||
}
|
||||
|
||||
const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
forecasts,
|
||||
const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
forecasts,
|
||||
validPeriod,
|
||||
areaMetadata
|
||||
}) => {
|
||||
// Singapore coordinates (centered on the island)
|
||||
const center: [number, number] = [1.3521, 103.8198];
|
||||
|
||||
|
||||
// Generate a custom icon based on the weather forecast
|
||||
const createWeatherIcon = (forecast: string) => {
|
||||
// Determine color based on the forecast
|
||||
@@ -83,7 +83,7 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
if (forecast.includes("Thunder")) color = "#9F7AEA"; // purple
|
||||
if (forecast.includes("Wind")) color = "#38B2AC"; // teal
|
||||
if (forecast.includes("Snow")) color = "#BEE3F8"; // light blue
|
||||
|
||||
|
||||
// Create an HTML element to render the icon
|
||||
const iconHtml = `<div style="
|
||||
background-color: white;
|
||||
@@ -100,7 +100,7 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
${getIconPath(forecast)}
|
||||
</svg>
|
||||
</div>`;
|
||||
|
||||
|
||||
// Create a custom divIcon with the HTML content
|
||||
return L.divIcon({
|
||||
html: iconHtml,
|
||||
@@ -110,10 +110,10 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
popupAnchor: [0, -32]
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Get SVG path for the icon based on forecast
|
||||
const getIconPath = (forecast: string) => {
|
||||
if (forecast.includes("Fair") || forecast.includes("Sun"))
|
||||
if (forecast.includes("Fair") || forecast.includes("Sun"))
|
||||
return '<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>';
|
||||
if (forecast.includes("Cloudy") || forecast.includes("Hazy") || forecast.includes("Cloud") && !forecast.includes("Rain") && !forecast.includes("Drizzle"))
|
||||
return '<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>';
|
||||
@@ -130,12 +130,12 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
// Default cloud icon
|
||||
return '<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>';
|
||||
};
|
||||
|
||||
|
||||
// For backward compatibility, still set the default icon
|
||||
useEffect(() => {
|
||||
L.Marker.prototype.options.icon = defaultIcon;
|
||||
}, []);
|
||||
|
||||
|
||||
// Helper function to get color for icons in popup
|
||||
const getIconColor = (forecast: string) => {
|
||||
if (forecast.includes("Fair")) return "#ECC94B"; // yellow
|
||||
@@ -154,21 +154,21 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Join area metadata with forecasts
|
||||
const forecastsWithLocations = forecasts.map(forecast => {
|
||||
const metadata = areaMetadata.find(area => area.name === forecast.area);
|
||||
const metadata = areaMetadata.find(area => area.name === forecast.code);
|
||||
return {
|
||||
...forecast,
|
||||
location: metadata?.label_location
|
||||
};
|
||||
}).filter(f => f.location); // Only include forecasts with location data
|
||||
|
||||
|
||||
return (
|
||||
<Box height="500px" width="100%" borderRadius="md" overflow="hidden" my={4}>
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={11}
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={11}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
@@ -176,22 +176,22 @@ const WeatherMap: React.FC<WeatherMapProps> = ({
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapCenter center={center} />
|
||||
|
||||
|
||||
{forecastsWithLocations.map((forecast, index) => (
|
||||
<Marker
|
||||
key={`${forecast.area}-${index}`}
|
||||
<Marker
|
||||
key={`${forecast.code}-${index}`}
|
||||
position={[forecast.location!.latitude, forecast.location!.longitude]}
|
||||
icon={createWeatherIcon(forecast.forecast)}
|
||||
icon={createWeatherIcon(forecast.text)}
|
||||
>
|
||||
<Popup>
|
||||
<Box p={1}>
|
||||
<Box fontWeight="bold">{forecast.area}</Box>
|
||||
<Box fontWeight="bold">{forecast.code}</Box>
|
||||
<Box display="flex" alignItems="center" gap={2} my={1}>
|
||||
{React.createElement(forecastIcons[forecast.forecast] || FiCloud, {
|
||||
color: getIconColor(forecast.forecast),
|
||||
{React.createElement(forecastIcons[forecast.text] || FiCloud, {
|
||||
color: getIconColor(forecast.text),
|
||||
size: 18
|
||||
})}
|
||||
<span>{forecast.forecast}</span>
|
||||
<span>{forecast.text}</span>
|
||||
</Box>
|
||||
<Box fontSize="xs" mt={1}>
|
||||
{formatDateTime(validPeriod.start)} - {formatDateTime(validPeriod.end)}
|
||||
|
||||
@@ -17,6 +17,8 @@ import WeatherErrorDisplay from "@/components/Weather/WeatherErrorDisplay"
|
||||
import WeatherLoadingAnimation from "@/components/Weather/WeatherLoadingAnimation"
|
||||
import WeatherForecast from "@/components/Weather/WeatherForecast"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import type { TwoHourForecastData, Forecast } from "@/utils/weatherTypes"
|
||||
import { assertTwoHourForecastResponse } from "@/utils/weatherTypes"
|
||||
|
||||
export const Route = createFileRoute("/_layout/weather")({
|
||||
component: Weather,
|
||||
@@ -27,7 +29,7 @@ function Weather() {
|
||||
const [selectedArea, setSelectedArea] = useState<string>("All Areas")
|
||||
|
||||
const {
|
||||
data,
|
||||
data: rawData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
@@ -39,6 +41,9 @@ function Weather() {
|
||||
staleTime: 1000 * 60 * 15, // Consider data stale after 15 minutes
|
||||
})
|
||||
|
||||
// Extract properly typed data from the generic response
|
||||
const data: TwoHourForecastData | null = rawData ? assertTwoHourForecastResponse(rawData) : null
|
||||
|
||||
// Format date for display
|
||||
const formatUpdateTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
@@ -70,25 +75,25 @@ function Weather() {
|
||||
|
||||
// Filter forecasts by area if selected
|
||||
const getFilteredForecasts = () => {
|
||||
if (!data?.data?.items || data.data.items.length === 0) {
|
||||
if (!data?.items || data.items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const forecasts = data.data.items[0].forecasts
|
||||
const forecasts = data.items[0].forecasts
|
||||
if (selectedArea === "All Areas") {
|
||||
return forecasts
|
||||
}
|
||||
|
||||
return forecasts.filter(forecast => forecast.area === selectedArea)
|
||||
return forecasts.filter((forecast: Forecast) => forecast.area === selectedArea)
|
||||
}
|
||||
|
||||
// Get unique areas for the dropdown
|
||||
const getUniqueAreas = () => {
|
||||
if (!data?.data?.items || data.data.items.length === 0) {
|
||||
if (!data?.items || data.items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const areas = data.data.items[0].forecasts.map(f => f.area)
|
||||
const areas = data.items[0].forecasts.map((f: Forecast) => f.area)
|
||||
return ["All Areas", ...areas].sort()
|
||||
}
|
||||
|
||||
@@ -114,7 +119,7 @@ function Weather() {
|
||||
<Box bg="whiteAlpha.200" p={6} borderRadius="md" shadow="md">
|
||||
{isLoading ? (
|
||||
<WeatherLoadingAnimation />
|
||||
) : data?.data?.items && data.data.items.length > 0 ? (
|
||||
) : data?.items && data.items.length > 0 ? (
|
||||
<>
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
@@ -126,7 +131,7 @@ function Weather() {
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Last Updated:{" "}
|
||||
{formatUpdateTime(data.data.items[0].update_timestamp)}
|
||||
{formatUpdateTime(data.items[0].update_timestamp)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Data fetched at: {formatLastFetchedTime(dataUpdatedAt)}
|
||||
@@ -162,9 +167,16 @@ function Weather() {
|
||||
</Flex>
|
||||
|
||||
<WeatherForecast
|
||||
forecasts={getFilteredForecasts()}
|
||||
validPeriod={data.data.items[0].valid_period}
|
||||
areaMetadata={data.data.area_metadata || []}
|
||||
forecasts={getFilteredForecasts().map((forecast: Forecast) => ({
|
||||
code: forecast.area,
|
||||
text: forecast.forecast
|
||||
}))}
|
||||
validPeriod={{
|
||||
start: data.items[0].valid_period.start,
|
||||
end: data.items[0].valid_period.end,
|
||||
text: data.items[0].valid_period.text
|
||||
}}
|
||||
areaMetadata={data.area_metadata || []}
|
||||
/>
|
||||
|
||||
<Text mt={6} fontSize="xs" color="gray.500">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Weather type utilities to work with the generic WeatherResponse
|
||||
import type { WeatherGetTwoHourForecastResponse, AreaMetadata } from '@/client/types.gen'
|
||||
|
||||
// Define the proper structure for two-hour forecast data
|
||||
export interface TwoHourForecastData {
|
||||
area_metadata: AreaMetadata[]
|
||||
items: TwoHourForecastItem[]
|
||||
paginationToken?: string | null
|
||||
}
|
||||
|
||||
export interface TwoHourForecastItem {
|
||||
update_timestamp: string
|
||||
timestamp: string
|
||||
valid_period: ForecastPeriod
|
||||
forecasts: Forecast[]
|
||||
}
|
||||
|
||||
export interface ForecastPeriod {
|
||||
start: string
|
||||
end: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Forecast {
|
||||
area: string
|
||||
forecast: string
|
||||
}
|
||||
|
||||
// Type guard to check if the response has the expected structure
|
||||
export function isTwoHourForecastData(data: unknown): data is TwoHourForecastData {
|
||||
if (!data || typeof data !== 'object') return false
|
||||
|
||||
const d = data as any
|
||||
return (
|
||||
Array.isArray(d.area_metadata) &&
|
||||
Array.isArray(d.items) &&
|
||||
d.items.length > 0 &&
|
||||
d.items[0].forecasts &&
|
||||
Array.isArray(d.items[0].forecasts)
|
||||
)
|
||||
}
|
||||
|
||||
// Type assertion utility
|
||||
export function assertTwoHourForecastResponse(
|
||||
response: WeatherGetTwoHourForecastResponse
|
||||
): TwoHourForecastData | null {
|
||||
if (!response?.data || !isTwoHourForecastData(response.data)) {
|
||||
return null
|
||||
}
|
||||
return response.data as TwoHourForecastData
|
||||
}
|
||||
Reference in New Issue
Block a user