Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions frontend/src/components/globals/CurrencyRateCard.tsx
Original file line number Diff line number Diff line change
@@ -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<AppLocale, string> = {
"en-GB": "English (UK)",
"zh-Hant-HK": "中文(繁體,香港)",
};

const localeToTargetCurrency: Record<AppLocale, "GBP" | "HKD" | "SGD"> = {
"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 (
<div className="card bg-base-100 border border-base-300 shadow-sm mt-4">
<div className="card-body gap-3">
<h2 className="card-title text-lg">
{t("currencyRate.title", { defaultValue: "Currency Rate" })}
</h2>

<div className="flex flex-wrap gap-2">
<span className="badge badge-outline">
{t("currencyRate.locale", { defaultValue: "Locale" })}: {localeLabels[resolvedLocale]}
</span>
<span className="badge badge-outline">
{t("currencyRate.sgd", { defaultValue: "SGD" })}: 1 SGD = {displayRate.toFixed(2)} {targetCurrency}
</span>
</div>

<p className="text-sm text-base-content/70">
{t("currencyRate.helper", {
defaultValue: "The display follows the current app localisation.",
})}
</p>
</div>
</div>
);
};

export default CurrencyRateCard;
237 changes: 237 additions & 0 deletions frontend/src/components/globals/WeatherCard.tsx
Original file line number Diff line number Diff line change
@@ -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<number, string> = {
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<WeatherSnapshot> {
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<WeatherLocationState[]>([]);

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 (
<div className="card bg-base-100 border border-base-300 shadow-sm mt-4">
<div className="card-body gap-4">
<h2 className="card-title text-lg">
{title ?? t("weather.title", { defaultValue: "Current Weather" })}
</h2>

<div className="space-y-3">
{locationStates.map((locationState) => {
const locationLabel = locationState.label ?? t("weather.unnamedLocation", {
defaultValue: "Location",
});

return (
<section key={`${locationState.latitude}-${locationState.longitude}`} className="rounded-box border border-base-300 p-4 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{locationLabel}</h3>
<span className="text-xs text-base-content/60">
{locationState.loading
? t("weather.loading", { defaultValue: "Loading weather..." })
: null}
</span>
</div>

{locationState.loading && (
<p className="text-base-content/70">{t("weather.loading", { defaultValue: "Loading weather..." })}</p>
)}

{locationState.error && !locationState.loading && (
<p className="text-error">{locationState.error}</p>
)}

{locationState.weather && !locationState.loading && !locationState.error && (
<>
<p className="text-base-content/80">
{weatherCodeToLabel(Math.round(locationState.weather.weatherCode))}
</p>

<div className="flex flex-wrap gap-2 text-sm">
<span className="badge badge-outline">
{locationState.weather.temperature.toFixed(1)}°C
</span>
<span className="badge badge-outline">
{t("weather.feelsLike", {
defaultValue: "Feels like",
})} {locationState.weather.apparentTemperature.toFixed(1)}°C
</span>
<span className="badge badge-outline">
{t("weather.wind", { defaultValue: "Wind" })} {locationState.weather.windSpeed.toFixed(1)} km/h
</span>
</div>

<p className="text-xs text-base-content/60">
{t("weather.updatedAt", { defaultValue: "Updated at" })} {" "}
<DateTimeDisplay
timestamp={locationState.weather.time}
preset="relativeDateTime"
/>
</p>
</>
)}
</section>
);
})}
</div>
</div>
</div>
);
};

export default WeatherCard;
Loading
Loading