refactor: Update weather components to use new forecast types and improve data handling

This commit is contained in:
2025-05-27 16:10:47 +08:00
parent 43af7fd38e
commit 513f1042de
4 changed files with 124 additions and 61 deletions
@@ -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>
+26 -26
View File
@@ -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)}
+23 -11
View File
@@ -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">
+51
View File
@@ -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
}