From 052cd36969a62a3f7eb49d862d0a4c835547db31 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 20 Apr 2026 13:46:15 +0100 Subject: [PATCH 1/4] feat: integrate Open Meteo API to fetch and display current weather for Singapore --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 25 +++++ frontend/src/pages/public/Home.tsx | 141 +++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index 55a084e..3abdc36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "i18next-locize-backend": "^9.0.1", "js-cookie": "^3.0.5", "leaflet": "^1.9.4", + "openmeteo": "^1.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.5.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0b3a19a..32bc28d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + openmeteo: + specifier: ^1.2.3 + version: 1.2.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -459,6 +462,10 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@openmeteo/sdk@1.26.0': + resolution: {integrity: sha512-e6uSY2O8LEAPX4GjmKswSSE/94II2HV3YJ/G+GoCZ0JyFJe3rtBtZYLRqgJPwp6OISQVduKJdAt5XkpglmSbew==} + engines: {node: '>=12.0'} + '@oxc-parser/binding-android-arm-eabi@0.112.0': resolution: {integrity: sha512-retxBzJ39Da7Lh/eZTn9+HJgTeDUxZIpuI0urOsmcFsBKXAth3lc1jIvwseQ9qbAI/VrsoFOXiGIzgclARbAHg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1187,6 +1194,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -1445,6 +1455,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + openmeteo@1.2.3: + resolution: {integrity: sha512-3updmgCMnCAAjAgV3RfhMdIBPi/43RtbjQ4CizHGn52RP9SpgmVCDRpun+Ex75NG3jTF3pZp+d1BPlP9DWMHiQ==} + engines: {node: '>=12.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2065,6 +2079,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@openmeteo/sdk@1.26.0': + dependencies: + flatbuffers: 25.9.23 + '@oxc-parser/binding-android-arm-eabi@0.112.0': optional: true @@ -2709,6 +2727,8 @@ snapshots: flatted: 3.3.3 keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.3.3: {} flowbite-react@0.12.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@4.2.1): @@ -2912,6 +2932,11 @@ snapshots: node-releases@2.0.27: {} + openmeteo@1.2.3: + dependencies: + '@openmeteo/sdk': 1.26.0 + flatbuffers: 25.9.23 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index c663798..8ffe3cc 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -1,4 +1,6 @@ +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { fetchWeatherApi } from 'openmeteo'; import Layout from "../layout"; import { useCurrency } from "../../context/CurrencyContext"; import { formatCurrency } from "../../utils/formatCurrency"; @@ -6,11 +8,117 @@ import HeroSection from "../../components/globals/HeroSection"; import Map from "../../components/maps/Map"; import { useLocation } from "../../context/LocationContext"; +type CurrentWeather = { + time: Date; + temperature: number; + apparentTemperature: number; + windSpeed: number; + weatherCode: number; +}; + +const SINGAPORE_COORDINATES = { + latitude: 1.3521, + longitude: 103.8198, +}; + +const weatherCodeToLabel = (weatherCode: number) => { + const weatherCodeMap: Record = { + 0: 'Clear sky', + 1: 'Mainly clear', + 2: 'Partly cloudy', + 3: 'Overcast', + 45: 'Fog', + 48: 'Depositing rime fog', + 51: 'Light drizzle', + 53: 'Moderate drizzle', + 55: 'Dense drizzle', + 61: 'Slight rain', + 63: 'Moderate rain', + 65: 'Heavy rain', + 80: 'Rain showers', + 95: 'Thunderstorm', + }; + + return weatherCodeMap[weatherCode] ?? 'Unknown'; +}; const Home = () => { const { t } = useTranslation(); const { currency, rate } = useCurrency(); const { location } = useLocation(); + const [weather, setWeather] = useState(null); + const [weatherLoading, setWeatherLoading] = useState(true); + const [weatherError, setWeatherError] = useState(null); + + useEffect(() => { + let isCancelled = false; + + async function getCurrentWeather() { + setWeatherLoading(true); + setWeatherError(null); + + try { + const params = { + latitude: SINGAPORE_COORDINATES.latitude, + longitude: SINGAPORE_COORDINATES.longitude, + current: [ + 'temperature_2m', + 'apparent_temperature', + 'weather_code', + 'wind_speed_10m', + ], + timezone: 'Asia/Singapore', + }; + + const responses = await fetchWeatherApi('https://api.open-meteo.com/v1/forecast', params); + const response = responses[0]; + const current = response.current(); + + if (!current) { + throw new Error('No current weather in response'); + } + + const utcOffsetSeconds = response.utcOffsetSeconds(); + const temperature = current.variables(0)?.value(); + const apparentTemperature = current.variables(1)?.value(); + const weatherCode = current.variables(2)?.value(); + const windSpeed = current.variables(3)?.value(); + + if ( + temperature == null || + apparentTemperature == null || + weatherCode == null || + windSpeed == null + ) { + throw new Error('Incomplete weather data in response'); + } + + if (!isCancelled) { + setWeather({ + time: new Date((Number(current.time()) + utcOffsetSeconds) * 1000), + temperature, + apparentTemperature, + weatherCode, + windSpeed, + }); + } + } catch { + if (!isCancelled) { + setWeatherError('Unable to load Singapore weather right now.'); + } + } finally { + if (!isCancelled) { + setWeatherLoading(false); + } + } + } + + void getCurrentWeather(); + + return () => { + isCancelled = true; + }; + }, []); return ( @@ -19,6 +127,39 @@ const Home = () => { +
+
+

Current Weather · Singapore

+ + {weatherLoading && ( +

Loading weather...

+ )} + + {weatherError && !weatherLoading && ( +

{weatherError}

+ )} + + {weather && !weatherLoading && !weatherError && ( + <> +

+ {weatherCodeToLabel(Math.round(weather.weatherCode))} +

+
+ {weather.temperature.toFixed(1)}°C + Feels like {weather.apparentTemperature.toFixed(1)}°C + Wind {weather.windSpeed.toFixed(1)} km/h +
+

+ Updated at {weather.time.toLocaleTimeString('en-SG', { + hour: '2-digit', + minute: '2-digit', + })} +

+ + )} +
+
+

{formatCurrency(1000 * rate, currency)}

{t('greeting', { ns: 'system' })}

From 63d9f721a334002327b6f28bbc382bb81612523f Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 20 Apr 2026 13:53:52 +0100 Subject: [PATCH 2/4] feat: implement WeatherCard component to fetch and display current weather for Singapore --- .../src/components/globals/WeatherCard.tsx | 237 ++++++++++++++++++ frontend/src/pages/public/Home.tsx | 143 +---------- 2 files changed, 239 insertions(+), 141 deletions(-) create mode 100644 frontend/src/components/globals/WeatherCard.tsx diff --git a/frontend/src/components/globals/WeatherCard.tsx b/frontend/src/components/globals/WeatherCard.tsx new file mode 100644 index 0000000..847280d --- /dev/null +++ b/frontend/src/components/globals/WeatherCard.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { fetchWeatherApi } from "openmeteo"; + +import { DateTimeDisplay } from "../../utils/formatDateTime"; + +export type WeatherLocation = { + label?: string; + latitude: number; + longitude: number; +}; + +export type WeatherSnapshot = { + time: string; + temperature: number; + apparentTemperature: number; + windSpeed: number; + weatherCode: number; +}; + +type WeatherLocationState = WeatherLocation & { + weather: WeatherSnapshot | null; + loading: boolean; + error: string | null; +}; + +type WeatherCardProps = { + locations?: WeatherLocation[] | null; + title?: string; +}; + +const DEFAULT_LOCATIONS: WeatherLocation[] = [ + { + label: "Singapore", + latitude: 1.3521, + longitude: 103.8198, + }, +]; + +const weatherCodeToLabel = (weatherCode: number) => { + const weatherCodeMap: Record = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 80: "Rain showers", + 95: "Thunderstorm", + }; + + return weatherCodeMap[weatherCode] ?? "Unknown"; +}; + +async function loadCurrentWeather(location: WeatherLocation): Promise { + const responses = await fetchWeatherApi("https://api.open-meteo.com/v1/forecast", { + latitude: location.latitude, + longitude: location.longitude, + current: [ + "temperature_2m", + "apparent_temperature", + "weather_code", + "wind_speed_10m", + ], + timezone: "Asia/Singapore", + }); + + const response = responses[0]; + const current = response.current(); + + if (!current) { + throw new Error("No current weather in response"); + } + + const utcOffsetSeconds = response.utcOffsetSeconds(); + const temperature = current.variables(0)?.value(); + const apparentTemperature = current.variables(1)?.value(); + const weatherCode = current.variables(2)?.value(); + const windSpeed = current.variables(3)?.value(); + + if ( + temperature == null || + apparentTemperature == null || + weatherCode == null || + windSpeed == null + ) { + throw new Error("Incomplete weather data in response"); + } + + return { + time: new Date((Number(current.time()) + utcOffsetSeconds) * 1000).toISOString(), + temperature, + apparentTemperature, + weatherCode, + windSpeed, + }; +} + +const WeatherCard = ({ locations, title }: WeatherCardProps) => { + const { t } = useTranslation(); + const [locationStates, setLocationStates] = useState([]); + + const resolvedLocations = locations?.length ? locations : DEFAULT_LOCATIONS; + + useEffect(() => { + let isCancelled = false; + + async function getWeather() { + setLocationStates( + resolvedLocations.map((location) => ({ + ...location, + weather: null, + loading: true, + error: null, + })), + ); + + const results = await Promise.allSettled( + resolvedLocations.map(async (location) => ({ + location, + weather: await loadCurrentWeather(location), + })), + ); + + if (isCancelled) { + return; + } + + setLocationStates( + results.map((result, index) => { + const location = resolvedLocations[index]; + + if (result.status === "fulfilled") { + return { + ...location, + weather: result.value.weather, + loading: false, + error: null, + }; + } + + return { + ...location, + weather: null, + loading: false, + error: t("weather.loadError", { + defaultValue: "Unable to load weather right now.", + }), + }; + }), + ); + } + + void getWeather(); + + return () => { + isCancelled = true; + }; + }, [resolvedLocations, t]); + + return ( +
+
+

+ {title ?? t("weather.title", { defaultValue: "Current Weather" })} +

+ +
+ {locationStates.map((locationState) => { + const locationLabel = locationState.label ?? t("weather.unnamedLocation", { + defaultValue: "Location", + }); + + return ( +
+
+

{locationLabel}

+ + {locationState.loading + ? t("weather.loading", { defaultValue: "Loading weather..." }) + : null} + +
+ + {locationState.loading && ( +

{t("weather.loading", { defaultValue: "Loading weather..." })}

+ )} + + {locationState.error && !locationState.loading && ( +

{locationState.error}

+ )} + + {locationState.weather && !locationState.loading && !locationState.error && ( + <> +

+ {weatherCodeToLabel(Math.round(locationState.weather.weatherCode))} +

+ +
+ + {locationState.weather.temperature.toFixed(1)}°C + + + {t("weather.feelsLike", { + defaultValue: "Feels like", + })} {locationState.weather.apparentTemperature.toFixed(1)}°C + + + {t("weather.wind", { defaultValue: "Wind" })} {locationState.weather.windSpeed.toFixed(1)} km/h + +
+ +

+ {t("weather.updatedAt", { defaultValue: "Updated at" })} {" "} + +

+ + )} +
+ ); + })} +
+
+
+ ); +}; + +export default WeatherCard; \ No newline at end of file diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 8ffe3cc..ba2a14d 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -1,124 +1,16 @@ -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { fetchWeatherApi } from 'openmeteo'; import Layout from "../layout"; import { useCurrency } from "../../context/CurrencyContext"; import { formatCurrency } from "../../utils/formatCurrency"; import HeroSection from "../../components/globals/HeroSection"; import Map from "../../components/maps/Map"; import { useLocation } from "../../context/LocationContext"; - -type CurrentWeather = { - time: Date; - temperature: number; - apparentTemperature: number; - windSpeed: number; - weatherCode: number; -}; - -const SINGAPORE_COORDINATES = { - latitude: 1.3521, - longitude: 103.8198, -}; - -const weatherCodeToLabel = (weatherCode: number) => { - const weatherCodeMap: Record = { - 0: 'Clear sky', - 1: 'Mainly clear', - 2: 'Partly cloudy', - 3: 'Overcast', - 45: 'Fog', - 48: 'Depositing rime fog', - 51: 'Light drizzle', - 53: 'Moderate drizzle', - 55: 'Dense drizzle', - 61: 'Slight rain', - 63: 'Moderate rain', - 65: 'Heavy rain', - 80: 'Rain showers', - 95: 'Thunderstorm', - }; - - return weatherCodeMap[weatherCode] ?? 'Unknown'; -}; +import WeatherCard from "../../components/globals/WeatherCard"; const Home = () => { const { t } = useTranslation(); const { currency, rate } = useCurrency(); const { location } = useLocation(); - const [weather, setWeather] = useState(null); - const [weatherLoading, setWeatherLoading] = useState(true); - const [weatherError, setWeatherError] = useState(null); - - useEffect(() => { - let isCancelled = false; - - async function getCurrentWeather() { - setWeatherLoading(true); - setWeatherError(null); - - try { - const params = { - latitude: SINGAPORE_COORDINATES.latitude, - longitude: SINGAPORE_COORDINATES.longitude, - current: [ - 'temperature_2m', - 'apparent_temperature', - 'weather_code', - 'wind_speed_10m', - ], - timezone: 'Asia/Singapore', - }; - - const responses = await fetchWeatherApi('https://api.open-meteo.com/v1/forecast', params); - const response = responses[0]; - const current = response.current(); - - if (!current) { - throw new Error('No current weather in response'); - } - - const utcOffsetSeconds = response.utcOffsetSeconds(); - const temperature = current.variables(0)?.value(); - const apparentTemperature = current.variables(1)?.value(); - const weatherCode = current.variables(2)?.value(); - const windSpeed = current.variables(3)?.value(); - - if ( - temperature == null || - apparentTemperature == null || - weatherCode == null || - windSpeed == null - ) { - throw new Error('Incomplete weather data in response'); - } - - if (!isCancelled) { - setWeather({ - time: new Date((Number(current.time()) + utcOffsetSeconds) * 1000), - temperature, - apparentTemperature, - weatherCode, - windSpeed, - }); - } - } catch { - if (!isCancelled) { - setWeatherError('Unable to load Singapore weather right now.'); - } - } finally { - if (!isCancelled) { - setWeatherLoading(false); - } - } - } - - void getCurrentWeather(); - - return () => { - isCancelled = true; - }; - }, []); return ( @@ -127,38 +19,7 @@ const Home = () => { -
-
-

Current Weather · Singapore

- - {weatherLoading && ( -

Loading weather...

- )} - - {weatherError && !weatherLoading && ( -

{weatherError}

- )} - - {weather && !weatherLoading && !weatherError && ( - <> -

- {weatherCodeToLabel(Math.round(weather.weatherCode))} -

-
- {weather.temperature.toFixed(1)}°C - Feels like {weather.apparentTemperature.toFixed(1)}°C - Wind {weather.windSpeed.toFixed(1)} km/h -
-

- Updated at {weather.time.toLocaleTimeString('en-SG', { - hour: '2-digit', - minute: '2-digit', - })} -

- - )} -
-
+

{formatCurrency(1000 * rate, currency)}

{t('greeting', { ns: 'system' })}

From c89ab8589eeb2be0a51a2500b33de7af05d5aa8a Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 20 Apr 2026 14:05:18 +0100 Subject: [PATCH 3/4] feat: add CurrencyRateCard component to display currency rates and integrate it into Home page --- .../components/globals/CurrencyRateCard.tsx | 62 +++++++++++++++++++ frontend/src/components/navigation/Navbar.tsx | 10 --- frontend/src/components/navigation/menu.ts | 7 +-- frontend/src/context/CurrencyContext.tsx | 8 +-- frontend/src/pages/public/Home.tsx | 4 +- 5 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/globals/CurrencyRateCard.tsx diff --git a/frontend/src/components/globals/CurrencyRateCard.tsx b/frontend/src/components/globals/CurrencyRateCard.tsx new file mode 100644 index 0000000..3eb8af0 --- /dev/null +++ b/frontend/src/components/globals/CurrencyRateCard.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; + +import { useCurrency } from "../../context/CurrencyContext"; +import { normalizeLocale, type AppLocale } from "../../i18n/locales"; + +type CurrencyRateCardProps = { + locale?: string | null; +}; + +const localeLabels: Record = { + "en-GB": "English (UK)", + "zh-Hant-HK": "中文(繁體,香港)", +}; + +const localeToTargetCurrency: Record = { + "en-GB": "GBP", + "zh-Hant-HK": "HKD", +}; + +const CurrencyRateCard = ({ locale }: CurrencyRateCardProps) => { + const { t } = useTranslation(); + const { currency, rate } = useCurrency(); + + const resolvedLocale = normalizeLocale(locale) ?? "en-GB"; + const targetCurrency = localeToTargetCurrency[resolvedLocale]; + const targetRate = currency === targetCurrency ? rate : undefined; + + const displayRate = + targetRate ?? + ({ + GBP: 0.58, + HKD: 5.85, + SGD: 1, + } as const)[targetCurrency]; + + return ( +
+
+

+ {t("currencyRate.title", { defaultValue: "Currency Rate" })} +

+ +
+ + {t("currencyRate.locale", { defaultValue: "Locale" })}: {localeLabels[resolvedLocale]} + + + {t("currencyRate.sgd", { defaultValue: "SGD" })}: 1 SGD = {displayRate.toFixed(2)} {targetCurrency} + +
+ +

+ {t("currencyRate.helper", { + defaultValue: "The display follows the current app localisation.", + })} +

+
+
+ ); +}; + +export default CurrencyRateCard; \ No newline at end of file diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index 6f9a0c7..3209b27 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -1,4 +1,3 @@ -import { CiSearch } from "react-icons/ci"; import { RxHamburgerMenu } from "react-icons/rx"; import logo from "../../assets/logo.png"; @@ -67,13 +66,6 @@ const ProfileDropdown = () => { > {t("navigation:profileMenu.profile")} - setOpen(false)} - > - {t("navigation:profileMenu.settings")} -