feat: weather widget - add wind speed, option to disable decimals for temperature & redesign dropdown forecast (#2099)
This commit is contained in:
@@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
|||||||
export const weatherRouter = createTRPCRouter({
|
export const weatherRouter = createTRPCRouter({
|
||||||
atLocation: publicProcedure.input(validation.widget.weather.atLocationInput).query(async ({ input }) => {
|
atLocation: publicProcedure.input(validation.widget.weather.atLocationInput).query(async ({ input }) => {
|
||||||
const res = await fetchWithTimeout(
|
const res = await fetchWithTimeout(
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=auto`,
|
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`,
|
||||||
);
|
);
|
||||||
const json: unknown = await res.json();
|
const json: unknown = await res.json();
|
||||||
const weather = await validation.widget.weather.atLocationOutput.parseAsync(json);
|
const weather = await validation.widget.weather.atLocationOutput.parseAsync(json);
|
||||||
@@ -18,6 +18,10 @@ export const weatherRouter = createTRPCRouter({
|
|||||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||||
maxTemp: weather.daily.temperature_2m_max[index],
|
maxTemp: weather.daily.temperature_2m_max[index],
|
||||||
minTemp: weather.daily.temperature_2m_min[index],
|
minTemp: weather.daily.temperature_2m_min[index],
|
||||||
|
sunrise: weather.daily.sunrise[index],
|
||||||
|
sunset: weather.daily.sunset[index],
|
||||||
|
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||||
|
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ const optionMapping: OptionMapping = {
|
|||||||
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
||||||
hasForecast: (oldOptions) => oldOptions.displayWeekly,
|
hasForecast: (oldOptions) => oldOptions.displayWeekly,
|
||||||
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
|
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
|
||||||
|
disableTemperatureDecimals: () => undefined,
|
||||||
|
showCurrentWindSpeed: () => undefined,
|
||||||
location: (oldOptions) => oldOptions.location,
|
location: (oldOptions) => oldOptions.location,
|
||||||
showCity: (oldOptions) => oldOptions.displayCityName,
|
showCity: (oldOptions) => oldOptions.displayCityName,
|
||||||
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
|
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
|
||||||
|
|||||||
@@ -1373,6 +1373,13 @@
|
|||||||
"isFormatFahrenheit": {
|
"isFormatFahrenheit": {
|
||||||
"label": "Temperature in Fahrenheit"
|
"label": "Temperature in Fahrenheit"
|
||||||
},
|
},
|
||||||
|
"disableTemperatureDecimals": {
|
||||||
|
"label": "Disable temperature decimals"
|
||||||
|
},
|
||||||
|
"showCurrentWindSpeed": {
|
||||||
|
"label": "Show current wind speed",
|
||||||
|
"description": "Only on current weather"
|
||||||
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"label": "Weather location"
|
"label": "Weather location"
|
||||||
},
|
},
|
||||||
@@ -1391,6 +1398,13 @@
|
|||||||
"description": "How the date should look like"
|
"description": "How the date should look like"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"currentWindSpeed": "{currentWindSpeed} km/h",
|
||||||
|
"dailyForecast": {
|
||||||
|
"sunrise": "Sunrise",
|
||||||
|
"sunset": "Sunset",
|
||||||
|
"maxWindSpeed": "Max wind speed: {maxWindSpeed} km/h",
|
||||||
|
"maxWindGusts": "Max wind gusts: {maxWindGusts} km/h"
|
||||||
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"mainlyClear": "Mainly clear",
|
"mainlyClear": "Mainly clear",
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ export const atLocationOutput = z.object({
|
|||||||
current_weather: z.object({
|
current_weather: z.object({
|
||||||
weathercode: z.number(),
|
weathercode: z.number(),
|
||||||
temperature: z.number(),
|
temperature: z.number(),
|
||||||
|
windspeed: z.number(),
|
||||||
}),
|
}),
|
||||||
daily: z.object({
|
daily: z.object({
|
||||||
time: z.array(z.string()),
|
time: z.array(z.string()),
|
||||||
weathercode: z.array(z.number()),
|
weathercode: z.array(z.number()),
|
||||||
temperature_2m_max: z.array(z.number()),
|
temperature_2m_max: z.array(z.number()),
|
||||||
temperature_2m_min: z.array(z.number()),
|
temperature_2m_min: z.array(z.number()),
|
||||||
|
sunrise: z.array(z.string()),
|
||||||
|
sunset: z.array(z.string()),
|
||||||
|
wind_speed_10m_max: z.array(z.number()),
|
||||||
|
wind_gusts_10m_max: z.array(z.number()),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
|
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
|
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { WeatherDescription, WeatherIcon } from "./icon";
|
import { WeatherDescription, WeatherIcon } from "./icon";
|
||||||
@@ -47,6 +48,7 @@ interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options">
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
||||||
|
const t = useScopedI18n("widget.weather");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group className="weather-day-group" gap="1cqmin">
|
<Group className="weather-day-group" gap="1cqmin">
|
||||||
@@ -60,15 +62,32 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Text fz="17.5cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
<Text fz="17.5cqmin">
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.current.temperature,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="1cqmin" />
|
<Space h="1cqmin" />
|
||||||
|
{options.showCurrentWindSpeed && (
|
||||||
|
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="1cqmin">
|
||||||
|
<IconWind size="12.5cqmin" />
|
||||||
|
<Text fz="10cqmin">{t("currentWindSpeed", { currentWindSpeed: weather.current.windspeed })}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Space h="1cqmin" />
|
||||||
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
||||||
<IconArrowUpRight size="12.5cqmin" />
|
<IconArrowUpRight size="12.5cqmin" />
|
||||||
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
|
<Text fz="10cqmin">
|
||||||
|
{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
||||||
|
</Text>
|
||||||
<Space w="2.5cqmin" />
|
<Space w="2.5cqmin" />
|
||||||
<IconArrowDownRight size="12.5cqmin" />
|
<IconArrowDownRight size="12.5cqmin" />
|
||||||
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
|
<Text fz="10cqmin">
|
||||||
|
{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{options.showCity && (
|
{options.showCity && (
|
||||||
<>
|
<>
|
||||||
@@ -108,7 +127,13 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
|||||||
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
<Text fz="20cqmin">
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.current.temperature,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="2.5cqmin" />
|
<Space h="2.5cqmin" />
|
||||||
<Forecast weather={weather} options={options} />
|
<Forecast weather={weather} options={options} />
|
||||||
@@ -134,7 +159,9 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
>
|
>
|
||||||
<Text fz="10cqmin">{dayjs(dayWeather.time).format("dd")}</Text>
|
<Text fz="10cqmin">{dayjs(dayWeather.time).format("dd")}</Text>
|
||||||
<WeatherIcon size="15cqmin" code={dayWeather.weatherCode} />
|
<WeatherIcon size="15cqmin" code={dayWeather.weatherCode} />
|
||||||
<Text fz="10cqmin">{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}</Text>
|
<Text fz="10cqmin">
|
||||||
|
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</HoverCard.Target>
|
</HoverCard.Target>
|
||||||
<HoverCard.Dropdown>
|
<HoverCard.Dropdown>
|
||||||
@@ -142,8 +169,20 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
dateFormat={dateFormat}
|
dateFormat={dateFormat}
|
||||||
time={dayWeather.time}
|
time={dayWeather.time}
|
||||||
weatherCode={dayWeather.weatherCode}
|
weatherCode={dayWeather.weatherCode}
|
||||||
maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}
|
maxTemp={getPreferredUnit(
|
||||||
minTemp={getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit)}
|
dayWeather.maxTemp,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
minTemp={getPreferredUnit(
|
||||||
|
dayWeather.minTemp,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
sunrise={dayjs(dayWeather.sunrise).format("HH:mm")}
|
||||||
|
sunset={dayjs(dayWeather.sunset).format("HH:mm")}
|
||||||
|
maxWindSpeed={dayWeather.maxWindSpeed}
|
||||||
|
maxWindGusts={dayWeather.maxWindGusts}
|
||||||
/>
|
/>
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
@@ -152,5 +191,9 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPreferredUnit = (value?: number, isFahrenheit = false): string =>
|
const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
|
||||||
value ? (isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`) : "?";
|
value
|
||||||
|
? isFahrenheit
|
||||||
|
? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F`
|
||||||
|
: `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
|
||||||
|
: "?";
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Stack, Text } from "@mantine/core";
|
import { List, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconCloud,
|
IconCloud,
|
||||||
IconCloudFog,
|
IconCloudFog,
|
||||||
IconCloudRain,
|
IconCloudRain,
|
||||||
IconCloudSnow,
|
IconCloudSnow,
|
||||||
IconCloudStorm,
|
IconCloudStorm,
|
||||||
|
IconMoon,
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconSnowflake,
|
IconSnowflake,
|
||||||
IconSun,
|
IconSun,
|
||||||
|
IconTemperatureMinus,
|
||||||
|
IconTemperaturePlus,
|
||||||
|
IconWind,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@@ -41,6 +45,10 @@ interface WeatherDescriptionProps {
|
|||||||
weatherCode: number;
|
weatherCode: number;
|
||||||
maxTemp?: string;
|
maxTemp?: string;
|
||||||
minTemp?: string;
|
minTemp?: string;
|
||||||
|
sunrise?: string;
|
||||||
|
sunset?: string;
|
||||||
|
maxWindSpeed?: number;
|
||||||
|
maxWindGusts?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +58,10 @@ interface WeatherDescriptionProps {
|
|||||||
* @param weatherCode weather code from api
|
* @param weatherCode weather code from api
|
||||||
* @param maxTemp preformatted string for max temperature
|
* @param maxTemp preformatted string for max temperature
|
||||||
* @param minTemp preformatted string for min temperature
|
* @param minTemp preformatted string for min temperature
|
||||||
|
* @param sunrise preformatted string for sunrise time
|
||||||
|
* @param sunset preformatted string for sunset time
|
||||||
|
* @param maxWindSpeed maximum wind speed
|
||||||
|
* @param maxWindGusts maximum wind gusts
|
||||||
* @returns Content for a HoverCard dropdown presenting weather information
|
* @returns Content for a HoverCard dropdown presenting weather information
|
||||||
*/
|
*/
|
||||||
export const WeatherDescription = ({
|
export const WeatherDescription = ({
|
||||||
@@ -59,6 +71,10 @@ export const WeatherDescription = ({
|
|||||||
weatherCode,
|
weatherCode,
|
||||||
maxTemp,
|
maxTemp,
|
||||||
minTemp,
|
minTemp,
|
||||||
|
sunrise,
|
||||||
|
sunset,
|
||||||
|
maxWindSpeed,
|
||||||
|
maxWindGusts,
|
||||||
}: WeatherDescriptionProps) => {
|
}: WeatherDescriptionProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const t = useScopedI18n("widget.weather");
|
||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
@@ -73,8 +89,14 @@ export const WeatherDescription = ({
|
|||||||
<Stack align="center" gap="0">
|
<Stack align="center" gap="0">
|
||||||
<Text fz="24px">{dayjs(time).format(dateFormat)}</Text>
|
<Text fz="24px">{dayjs(time).format(dateFormat)}</Text>
|
||||||
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
||||||
<Text fz="16px">{`${tCommon("information.max")}: ${maxTemp}`}</Text>
|
<List>
|
||||||
<Text fz="16px">{`${tCommon("information.min")}: ${minTemp}`}</Text>
|
<List.Item icon={<IconTemperaturePlus size={15} />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
||||||
|
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
||||||
|
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
||||||
|
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
|
||||||
|
<List.Item icon={<IconWind size={15} />}>{t("dailyForecast.maxWindSpeed", { maxWindSpeed })}</List.Item>
|
||||||
|
<List.Item icon={<IconWind size={15} />}>{t("dailyForecast.maxWindGusts", { maxWindGusts })}</List.Item>
|
||||||
|
</List>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
options: optionsBuilder.from(
|
options: optionsBuilder.from(
|
||||||
(factory) => ({
|
(factory) => ({
|
||||||
isFormatFahrenheit: factory.switch(),
|
isFormatFahrenheit: factory.switch(),
|
||||||
|
disableTemperatureDecimals: factory.switch(),
|
||||||
|
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
||||||
location: factory.location({
|
location: factory.location({
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
name: "Paris",
|
name: "Paris",
|
||||||
|
|||||||
Reference in New Issue
Block a user