mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 02:06:47 +00:00
perf: optimize frontend with code splitting, memoization, and tree shaking
- Implemented lazy loading for weather hub tabs - Added React.memo to weather components for performance - Optimized data processing with useMemo in dashboard - Enabled tree-shaking in Vite build - Improved bundle size by removing unused imports
This commit is contained in:
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <DashboardLoading />
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
if (error || !processedData) {
|
||||
return (
|
||||
<Container maxW="full" py={8}>
|
||||
<Card.Root>
|
||||
@@ -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 (
|
||||
<Container maxW="full" py={8}>
|
||||
@@ -248,12 +261,12 @@ function FuryDashboard() {
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<Text maxW="4xl">{buildComfortSummary(data)}</Text>
|
||||
<Text maxW="4xl">{buildComfortSummary(processedData)}</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
|
||||
{Object.values(data.metrics).map((metric) => (
|
||||
{Object.values(metrics).map((metric) => (
|
||||
<Card.Root key={metric.key}>
|
||||
<Card.Body gap={5}>
|
||||
<Flex justify="space-between" gap={4}>
|
||||
@@ -341,7 +354,7 @@ function FuryDashboard() {
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
Last fetch {formatTimestamp(data.generatedAt)}
|
||||
Last fetch {formatTimestamp(generatedAt)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
@@ -355,7 +368,7 @@ function FuryDashboard() {
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.recentSamples.map((sample) => (
|
||||
{recentSamples.map((sample) => (
|
||||
<Table.Row key={sample.updateTime}>
|
||||
<Table.Cell>{formatTimestamp(sample.updateTime)}</Table.Cell>
|
||||
<Table.Cell>{sample.temperature ?? "--"}</Table.Cell>
|
||||
@@ -372,4 +385,4 @@ function FuryDashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
export default FuryDashboard
|
||||
export default React.memo(FuryDashboard)
|
||||
@@ -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 <Spinner />;
|
||||
if (error || !data?.data) return <Text color="red.500">Error loading temperature data</Text>;
|
||||
if (error || !processedData.stations.length || !processedData.readings.length) return <Text color="red.500">Error loading temperature data</Text>;
|
||||
|
||||
const { stations = [], readings = [] } = data.data;
|
||||
if (stations.length === 0 || readings.length === 0) return <Text>No temperature data available</Text>;
|
||||
|
||||
const latestReading = readings[0];
|
||||
const { stations, latestReading } = processedData;
|
||||
|
||||
if (!latestReading || !latestReading.data) return <Text>No temperature data available</Text>;
|
||||
|
||||
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 = () => {
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<Heading size="md" mb={2}>Average Temperature</Heading>
|
||||
<Text fontSize="3xl" fontWeight="bold">{avgTemp.toFixed(1)}°C</Text>
|
||||
<Text fontSize="3xl" fontWeight="bold">{avgTempValue.toFixed(1)}°C</Text>
|
||||
<Text fontSize="sm" color="gray.500">Based on {validReadings.length} station readings</Text>
|
||||
<Text fontSize="xs" color="gray.400" mt={1}>Last updated: {new Date(latestReading.timestamp).toLocaleString()}</Text>
|
||||
</Card.Body>
|
||||
@@ -61,4 +73,4 @@ const AirTemperature: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AirTemperature;
|
||||
export default React.memo(AirTemperature);
|
||||
|
||||
@@ -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 <Spinner />;
|
||||
if (error || !data?.data) return <Text color="red.500">Error loading forecast data</Text>;
|
||||
if (error || !processedData.items.length) return <Text color="red.500">Error loading forecast data</Text>;
|
||||
|
||||
const items = data.data.items || [];
|
||||
if (!items.length) return <Text>No forecast data available</Text>;
|
||||
|
||||
const forecasts = items[0].forecasts || [];
|
||||
const validPeriod = items[0].valid_period;
|
||||
const { forecasts, validPeriod } = processedData;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -65,11 +72,11 @@ const TwoHourForecast: React.FC = () => {
|
||||
)}
|
||||
{viewMode === 'map' && (
|
||||
<Box height="500px" width="100%" borderRadius="md" overflow="hidden" my={4}>
|
||||
<WeatherMap forecastData={data} />
|
||||
<WeatherMap />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoHourForecast;
|
||||
export default React.memo(TwoHourForecast);
|
||||
|
||||
@@ -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<TwoHourForecastPayload>;
|
||||
tempData?: ApiNestedResponse<AirTemperaturePayload>;
|
||||
windData?: ApiNestedResponse<WindDirectionPayload>;
|
||||
}
|
||||
|
||||
const center: [number, number] = [1.3521, 103.8198];
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
@@ -97,7 +91,7 @@ const makeForecastMarkerHtml = ({
|
||||
</div>
|
||||
`;
|
||||
|
||||
const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windData }) => {
|
||||
const WeatherMap: React.FC<{ forecastData?: ApiNestedResponse<TwoHourForecastPayload>; tempData?: ApiNestedResponse<AirTemperaturePayload>; windData?: ApiNestedResponse<WindDirectionPayload>; }> = ({ forecastData, tempData, windData }) => {
|
||||
const { data: fetchedTempData, isLoading: tempLoading, error: tempError } = useQuery<ApiNestedResponse<AirTemperaturePayload>>({
|
||||
queryKey: ['weather', 'air-temperature'],
|
||||
queryFn: () => WeatherService.getAirTemperature() as Promise<ApiNestedResponse<AirTemperaturePayload>>,
|
||||
@@ -133,42 +127,76 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
|
||||
return <Text color="red.500">Error loading map data</Text>;
|
||||
}
|
||||
|
||||
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 <Text>No map data available</Text>;
|
||||
}
|
||||
|
||||
const latestTempReading = tempReadings[0];
|
||||
const latestWindReading = windReadings[0];
|
||||
|
||||
if (!latestTempReading || !latestTempReading.data) {
|
||||
return <Text>No map data available</Text>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box className="weather-map-shell">
|
||||
@@ -249,4 +277,4 @@ const WeatherMap: React.FC<WeatherMapProps> = ({ forecastData, tempData, windDat
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherMap;
|
||||
export default React.memo(WeatherMap);
|
||||
@@ -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() {
|
||||
|
||||
<ChakraTabs.ContentGroup>
|
||||
<ChakraTabs.Content value={tabValues.twoHour}>
|
||||
<TwoHourForecast />
|
||||
<React.Suspense fallback={<Box>Loading forecast...</Box>}>
|
||||
<TwoHourForecast />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
<ChakraTabs.Content value={tabValues.airTemp}>
|
||||
<AirTemperature />
|
||||
<React.Suspense fallback={<Box>Loading temperature...</Box>}>
|
||||
<AirTemperature />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
<ChakraTabs.Content value={tabValues.fourDay}>
|
||||
<FourDayOutlook />
|
||||
<React.Suspense fallback={<Box>Loading forecast...</Box>}>
|
||||
<FourDayOutlook />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
<ChakraTabs.Content value={tabValues.stats}>
|
||||
<WeatherStatistics />
|
||||
<React.Suspense fallback={<Box>Loading statistics...</Box>}>
|
||||
<WeatherStatistics />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
<ChakraTabs.Content value={tabValues.windDir}>
|
||||
<WindDirection />
|
||||
<React.Suspense fallback={<Box>Loading wind data...</Box>}>
|
||||
<WindDirection />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
<ChakraTabs.Content value={tabValues.weatherMap}>
|
||||
<WeatherMap />
|
||||
<React.Suspense fallback={<Box>Loading map...</Box>}>
|
||||
<WeatherMap />
|
||||
</React.Suspense>
|
||||
</ChakraTabs.Content>
|
||||
</ChakraTabs.ContentGroup>
|
||||
</ChakraTabs.Root>
|
||||
|
||||
+19
-1
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user