diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 03a165c..65f0784 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -36,7 +36,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react-swc": "^3.9.0",
"dotenv": "^16.5.0",
- "typescript": "^5.8.3",
+ "typescript": "^5.9.3",
"vite": "^6.3.5"
}
},
@@ -5095,9 +5095,9 @@
}
},
"node_modules/typescript": {
- "version": "5.8.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
- "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
diff --git a/frontend/package.json b/frontend/package.json
index af63951..7ba40f7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -39,7 +39,7 @@
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react-swc": "^3.9.0",
"dotenv": "^16.5.0",
- "typescript": "^5.8.3",
+ "typescript": "^5.9.3",
"vite": "^6.3.5"
}
}
diff --git a/frontend/src/components/dashboard/FuryDashboard.tsx b/frontend/src/components/dashboard/FuryDashboard.tsx
index 7d4f410..e0c581b 100644
--- a/frontend/src/components/dashboard/FuryDashboard.tsx
+++ b/frontend/src/components/dashboard/FuryDashboard.tsx
@@ -1,4 +1,4 @@
-import {
+import React, {
Badge,
Box,
Button,
@@ -14,7 +14,7 @@ import {
VStack,
} from "@chakra-ui/react"
import { useQuery } from "@tanstack/react-query"
-import { useState } from "react"
+import { useState, useMemo } from "react"
import useAuth from "@/hooks/useAuth"
@@ -160,11 +160,31 @@ function FuryDashboard() {
retry: 1,
})
+ // Memoize the data processing to avoid recomputing on every render
+ const processedData = useMemo(() => {
+ if (!data) return null;
+
+ const latestSampleTimes = Object.values(data.metrics)
+ .map((metric) => metric.latest?.updateTime)
+ .filter((value): value is string => Boolean(value))
+ .sort((left, right) =>
+ new Date(right).getTime() - new Date(left).getTime(),
+ )
+ const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
+ const welcomeName = currentUser?.full_name || currentUser?.email || "there"
+
+ return {
+ ...data,
+ newestSampleTime,
+ welcomeName
+ }
+ }, [data, currentUser])
+
if (isLoading) {
return
}
- if (error || !data) {
+ if (error || !processedData) {
return (
@@ -182,14 +202,7 @@ function FuryDashboard() {
)
}
- const latestSampleTimes = Object.values(data.metrics)
- .map((metric) => metric.latest?.updateTime)
- .filter((value): value is string => Boolean(value))
- .sort((left, right) =>
- new Date(right).getTime() - new Date(left).getTime(),
- )
- const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
- const welcomeName = currentUser?.full_name || currentUser?.email || "there"
+ const { generatedAt, metrics, recentSamples, newestSampleTime, welcomeName } = processedData
return (
@@ -248,12 +261,12 @@ function FuryDashboard() {
))}
- {buildComfortSummary(data)}
+ {buildComfortSummary(processedData)}
- {Object.values(data.metrics).map((metric) => (
+ {Object.values(metrics).map((metric) => (
@@ -341,7 +354,7 @@ function FuryDashboard() {
- Last fetch {formatTimestamp(data.generatedAt)}
+ Last fetch {formatTimestamp(generatedAt)}
@@ -355,7 +368,7 @@ function FuryDashboard() {
- {data.recentSamples.map((sample) => (
+ {recentSamples.map((sample) => (
{formatTimestamp(sample.updateTime)}
{sample.temperature ?? "--"}
@@ -372,4 +385,4 @@ function FuryDashboard() {
)
}
-export default FuryDashboard
\ No newline at end of file
+export default React.memo(FuryDashboard)
\ No newline at end of file
diff --git a/frontend/src/components/weather/AirTemperature.tsx b/frontend/src/components/weather/AirTemperature.tsx
index 06f7ae5..7ff2bea 100644
--- a/frontend/src/components/weather/AirTemperature.tsx
+++ b/frontend/src/components/weather/AirTemperature.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Spinner, Text, Box, VStack, SimpleGrid, Heading } from '@chakra-ui/react';
import { Card } from '@chakra-ui/react';
@@ -18,18 +18,30 @@ const AirTemperature: React.FC = () => {
refetchInterval: 1000 * 60 * 30,
});
+ // Memoize the data processing to avoid recomputing on every render
+ const processedData = useMemo(() => {
+ if (!data?.data) return { stations: [], readings: [], latestReading: null, avgTemp: 0 };
+
+ const { stations = [], readings = [] } = data.data;
+ const latestReading = readings[0];
+ const validReadings = latestReading?.data?.filter(r => r.value !== null) || [];
+ const avgTemp = validReadings.length > 0
+ ? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length
+ : 0;
+
+ return { stations, readings, latestReading, avgTemp };
+ }, [data]);
+
if (isLoading) return ;
- if (error || !data?.data) return Error loading temperature data;
+ if (error || !processedData.stations.length || !processedData.readings.length) return Error loading temperature data;
- const { stations = [], readings = [] } = data.data;
- if (stations.length === 0 || readings.length === 0) return No temperature data available;
-
- const latestReading = readings[0];
+ const { stations, latestReading } = processedData;
+
if (!latestReading || !latestReading.data) return No temperature data available;
const stationMap = new Map(stations.map(s => [s.id, s.name]));
const validReadings = latestReading.data.filter(r => r.value !== null);
- const avgTemp = validReadings.length > 0
+ const avgTempValue = validReadings.length > 0
? validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length
: 0;
@@ -39,7 +51,7 @@ const AirTemperature: React.FC = () => {
Average Temperature
- {avgTemp.toFixed(1)}°C
+ {avgTempValue.toFixed(1)}°C
Based on {validReadings.length} station readings
Last updated: {new Date(latestReading.timestamp).toLocaleString()}
@@ -61,4 +73,4 @@ const AirTemperature: React.FC = () => {
);
};
-export default AirTemperature;
+export default React.memo(AirTemperature);
diff --git a/frontend/src/components/weather/TwoHourForecast.tsx b/frontend/src/components/weather/TwoHourForecast.tsx
index 3a0f93d..1e1c773 100644
--- a/frontend/src/components/weather/TwoHourForecast.tsx
+++ b/frontend/src/components/weather/TwoHourForecast.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Box, Flex, Text, HStack, Badge, SimpleGrid, Spinner } from '@chakra-ui/react';
import { WeatherService } from '@/client/sdk.gen';
@@ -21,14 +21,21 @@ const TwoHourForecast: React.FC = () => {
refetchInterval: 1000 * 60 * 30,
});
+ // Memoize the data processing to avoid recomputing on every render
+ const processedData = useMemo(() => {
+ if (!data?.data) return { items: [], forecasts: [], validPeriod: null };
+
+ const items = data.data.items || [];
+ const forecasts = items[0]?.forecasts || [];
+ const validPeriod = items[0]?.valid_period || null;
+
+ return { items, forecasts, validPeriod };
+ }, [data]);
+
if (isLoading) return ;
- if (error || !data?.data) return Error loading forecast data;
+ if (error || !processedData.items.length) return Error loading forecast data;
- const items = data.data.items || [];
- if (!items.length) return No forecast data available;
-
- const forecasts = items[0].forecasts || [];
- const validPeriod = items[0].valid_period;
+ const { forecasts, validPeriod } = processedData;
return (
@@ -65,11 +72,11 @@ const TwoHourForecast: React.FC = () => {
)}
{viewMode === 'map' && (
-
+
)}
);
};
-export default TwoHourForecast;
+export default React.memo(TwoHourForecast);
diff --git a/frontend/src/components/weather/WeatherMap.tsx b/frontend/src/components/weather/WeatherMap.tsx
index deb449d..a1803d4 100644
--- a/frontend/src/components/weather/WeatherMap.tsx
+++ b/frontend/src/components/weather/WeatherMap.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Spinner, Text, Box } from '@chakra-ui/react';
import { MapContainer, TileLayer, Marker, Popup, ScaleControl, ZoomControl } from 'react-leaflet';
@@ -34,12 +34,6 @@ interface ForecastWithLocation {
location: { latitude: number; longitude: number };
}
-interface WeatherMapProps {
- forecastData?: ApiNestedResponse;
- tempData?: ApiNestedResponse;
- windData?: ApiNestedResponse;
-}
-
const center: [number, number] = [1.3521, 103.8198];
const escapeHtml = (value: string) =>
@@ -97,7 +91,7 @@ const makeForecastMarkerHtml = ({
`;
-const WeatherMap: React.FC = ({ forecastData, tempData, windData }) => {
+const WeatherMap: React.FC<{ forecastData?: ApiNestedResponse; tempData?: ApiNestedResponse; windData?: ApiNestedResponse; }> = ({ forecastData, tempData, windData }) => {
const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery>({
queryKey: ['weather', 'air-temperature'],
queryFn: () => WeatherService.getAirTemperature() as Promise>,
@@ -133,42 +127,76 @@ const WeatherMap: React.FC = ({ forecastData, tempData, windDat
return Error loading map data;
}
- const tempStations = actualTempData.data?.stations || [];
- const tempReadings = actualTempData.data?.readings || [];
- const windReadings = actualWindData.data?.readings || [];
- const items = actualForecastData.data?.items || [];
- const forecasts = items[0]?.forecasts || [];
- const areaMetadata = actualForecastData.data?.area_metadata || [];
+ // Memoize the data processing to avoid recomputing on every render
+ const processedData = useMemo(() => {
+ const tempStations = actualTempData.data?.stations || [];
+ const tempReadings = actualTempData.data?.readings || [];
+ const windReadings = actualWindData.data?.readings || [];
+ const items = actualForecastData.data?.items || [];
+ const forecasts = items[0]?.forecasts || [];
+ const areaMetadata = actualForecastData.data?.area_metadata || [];
- if (tempStations.length === 0 || tempReadings.length === 0) {
+ if (tempStations.length === 0 || tempReadings.length === 0) {
+ return null;
+ }
+
+ const latestTempReading = tempReadings[0];
+ const latestWindReading = windReadings[0];
+
+ if (!latestTempReading || !latestTempReading.data) {
+ return null;
+ }
+
+ const latestReadingTime = formatTime(latestTempReading.timestamp);
+ const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
+ const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
+
+ const getCardinalDirection = (deg: number | null | undefined) => {
+ if (deg === null || deg === undefined) return 'N/A';
+ const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
+ const idx = Math.round((deg % 360) / 22.5) % 16;
+ return dirs[idx];
+ };
+
+ const forecastsWithLocation = forecasts
+ .map(f => ({
+ area: f.area,
+ forecast: f.forecast,
+ location: areaMetadata.find(a => a.name === f.area)?.label_location,
+ }))
+ .filter(f => f.location) as ForecastWithLocation[];
+
+ return {
+ tempStations,
+ tempReadings,
+ windReadings,
+ items,
+ forecasts,
+ areaMetadata,
+ latestTempReading,
+ latestWindReading,
+ latestReadingTime,
+ tempStationMap,
+ windDataMap,
+ getCardinalDirection,
+ forecastsWithLocation
+ };
+ }, [actualTempData, actualWindData, actualForecastData]);
+
+ if (!processedData) {
return No map data available;
}
- const latestTempReading = tempReadings[0];
- const latestWindReading = windReadings[0];
-
- if (!latestTempReading || !latestTempReading.data) {
- return No map data available;
- }
-
- const latestReadingTime = formatTime(latestTempReading.timestamp);
- const tempStationMap = new Map(tempStations.map((st: AirTempStation) => [st.id, st]));
- const windDataMap = new Map(latestWindReading?.data?.map((rd: WindDataPoint) => [rd.stationId, rd.value]) || []);
-
- const getCardinalDirection = (deg: number | null | undefined) => {
- if (deg === null || deg === undefined) return 'N/A';
- const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
- const idx = Math.round((deg % 360) / 22.5) % 16;
- return dirs[idx];
- };
-
- const forecastsWithLocation = forecasts
- .map(f => ({
- area: f.area,
- forecast: f.forecast,
- location: areaMetadata.find(a => a.name === f.area)?.label_location,
- }))
- .filter(f => f.location) as ForecastWithLocation[];
+ const {
+ tempStations,
+ latestTempReading,
+ latestWindReading,
+ latestReadingTime,
+ tempStationMap,
+ windDataMap,
+ getCardinalDirection,
+ forecastsWithLocation
+ } = processedData;
return (
@@ -249,4 +277,4 @@ const WeatherMap: React.FC = ({ forecastData, tempData, windDat
);
};
-export default WeatherMap;
\ No newline at end of file
+export default React.memo(WeatherMap);
\ No newline at end of file
diff --git a/frontend/src/routes/_layout/weather-hub.tsx b/frontend/src/routes/_layout/weather-hub.tsx
index 52e55be..d64aba5 100644
--- a/frontend/src/routes/_layout/weather-hub.tsx
+++ b/frontend/src/routes/_layout/weather-hub.tsx
@@ -1,12 +1,14 @@
+import React from 'react';
import { Container, Box, Heading, Text, Separator, Tabs as ChakraTabs } from '@chakra-ui/react';
import { createFileRoute } from '@tanstack/react-router';
-import TwoHourForecast from '@/components/weather/TwoHourForecast';
-import AirTemperature from '@/components/weather/AirTemperature';
-import FourDayOutlook from '@/components/weather/FourDayOutlook';
-import WeatherStatistics from '@/components/weather/WeatherStatistics';
-import WindDirection from '@/components/weather/WindDirection';
-import WeatherMap from '@/components/weather/WeatherMap';
+// Lazy-load components to improve initial bundle size
+const TwoHourForecast = React.lazy(() => import('@/components/weather/TwoHourForecast'));
+const AirTemperature = React.lazy(() => import('@/components/weather/AirTemperature'));
+const FourDayOutlook = React.lazy(() => import('@/components/weather/FourDayOutlook'));
+const WeatherStatistics = React.lazy(() => import('@/components/weather/WeatherStatistics'));
+const WindDirection = React.lazy(() => import('@/components/weather/WindDirection'));
+const WeatherMap = React.lazy(() => import('@/components/weather/WeatherMap'));
const tabValues = {
twoHour: 'twoHourForecast',
@@ -44,22 +46,34 @@ function WeatherHub() {
-
+ Loading forecast...}>
+
+
-
+ Loading temperature...}>
+
+
-
+ Loading forecast...}>
+
+
-
+ Loading statistics...}>
+
+
-
+ Loading wind data...}>
+
+
-
+ Loading map...}>
+
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b3db6da..3fd96d6 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -10,7 +10,13 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
- plugins: [react(), TanStackRouterVite()],
+ plugins: [
+ react({
+ // Enable JSX runtime for React 19
+ jsxRuntime: "automatic"
+ }),
+ TanStackRouterVite(),
+ ],
server: {
allowedHosts: [
"dev.lan",
@@ -31,4 +37,16 @@ export default defineConfig({
},
},
},
+ build: {
+ // Enable tree-shaking by default
+ rollupOptions: {
+ output: {
+ // Split vendor code into separate chunks for better caching
+ manualChunks: {
+ vendor: ["react", "react-dom", "@tanstack/react-query", "@chakra-ui/react"],
+ leaflet: ["leaflet", "react-leaflet"],
+ },
+ },
+ },
+ },
})