From cfd8b328b40352c4f517853b6f019042ddf57549 Mon Sep 17 00:00:00 2001 From: SeDemal Date: Wed, 5 Jun 2024 21:26:59 +0200 Subject: [PATCH] feat: weather widget scalable (#574) * refactor: Make weather widget scalable * fix: formatting * fix: map key again * fix: null assertions --- .vscode/settings.json | 3 +- .../src/components/board/sections/content.tsx | 4 +- packages/api/src/router/widgets/weather.ts | 27 ++- packages/translation/src/lang/en.ts | 7 + packages/widgets/src/weather/component.tsx | 180 ++++++++++-------- packages/widgets/src/weather/icon.tsx | 59 +++++- 6 files changed, 174 insertions(+), 106 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b9a963aad..b9e6ea443 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,9 @@ "js/ts.implicitProjectConfig.experimentalDecorators": true, "prettier.configPath": "./tooling/prettier/index.mjs", "cSpell.words": [ - "superjson", + "cqmin", "homarr", + "superjson", "trpc", "Umami" ] diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 487207eb4..10d26a236 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -65,14 +65,13 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => { gs-h={item.height} gs-min-w={1} gs-min-h={1} - gs-max-w={4} - gs-max-h={4} ref={refs.items.current[item.id] as RefObject} > { styles={{ root: { "--opacity": opacity / 100, + containerType: "size", }, }} p={0} diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts index a5440fd73..ab59ef50f 100644 --- a/packages/api/src/router/widgets/weather.ts +++ b/packages/api/src/router/widgets/weather.ts @@ -3,13 +3,22 @@ import { validation } from "@homarr/validation"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const weatherRouter = createTRPCRouter({ - atLocation: publicProcedure - .input(validation.widget.weather.atLocationInput) - .output(validation.widget.weather.atLocationOutput) - .query(async ({ input }) => { - const res = await fetch( - `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`, - ); - return res.json(); - }), + atLocation: publicProcedure.input(validation.widget.weather.atLocationInput).query(async ({ input }) => { + const res = await fetch( + `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`, + ); + const json: unknown = await res.json(); + const weather = await validation.widget.weather.atLocationOutput.parseAsync(json); + return { + current: weather.current_weather, + daily: weather.daily.time.map((value, index) => { + return { + time: value, + weatherCode: weather.daily.weathercode[index] ?? 404, + maxTemp: weather.daily.temperature_2m_max[index], + minTemp: weather.daily.temperature_2m_min[index], + }; + }) ?? [{ time: 0, weatherCode: 404 }], + }; + }), }); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 58cf676d2..3118b9bc9 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -429,6 +429,9 @@ export default { }, common: { rtl: "{value}{symbol}", + symbols: { + colon: ": ", + }, action: { add: "Add", apply: "Apply", @@ -452,6 +455,10 @@ export default { iconPicker: { header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.", }, + information: { + min: "Min", + max: "Max", + }, notification: { create: { success: "Creation successful", diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index cbe2ad5f4..d7ad12484 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,13 +1,15 @@ -import { Card, Flex, Group, Stack, Text, Title } from "@mantine/core"; +import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react"; +import combineClasses from "clsx"; +import dayjs from "dayjs"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import type { WidgetComponentProps } from "../definition"; -import { WeatherIcon } from "./icon"; +import { WeatherDescription, WeatherIcon } from "./icon"; -export default function WeatherWidget({ options, width }: WidgetComponentProps<"weather">) { +export default function WeatherWidget({ options }: WidgetComponentProps<"weather">) { const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery( { latitude: options.location.latitude, @@ -21,113 +23,123 @@ export default function WeatherWidget({ options, width }: WidgetComponentProps<" ); return ( - - - + + {options.hasForecast ? ( + + ) : ( + + )} ); } -interface DailyWeatherProps extends Pick, "width" | "options"> { - shouldHide: boolean; +interface WeatherProps extends Pick, "options"> { weather: RouterOutputs["widget"]["weather"]["atLocation"]; } -const DailyWeather = ({ shouldHide, width, options, weather }: DailyWeatherProps) => { - if (shouldHide) { - return null; - } - +const DailyWeather = ({ options, weather }: WeatherProps) => { return ( <> - - - {getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)} - - - {width > 200 && ( - - - {getPreferredUnit(weather.daily.temperature_2m_max[0]!, options.isFormatFahrenheit)} - - {getPreferredUnit(weather.daily.temperature_2m_min[0]!, options.isFormatFahrenheit)} - - )} - + + + + + + + + + + + + {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)} + + + + + {getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)} + + + {getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)} + {options.showCity && ( - - - {options.location.name} - + <> + + + + + {options.location.name} + + + )} ); }; -interface WeeklyForecastProps extends Pick, "width" | "options"> { - shouldHide: boolean; - weather: RouterOutputs["widget"]["weather"]["atLocation"]; -} - -const WeeklyForecast = ({ shouldHide, width, options, weather }: WeeklyForecastProps) => { - if (shouldHide) { - return null; - } - +const WeeklyForecast = ({ options, weather }: WeatherProps) => { return ( <> - + {options.showCity && ( - - - + <> + + {options.location.name} - + + )} - - 20 ? "red" : "blue"}> - {getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)} - - - + + + + + + + + + + + {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)} + + + ); }; -interface ForecastProps extends Pick, "options" | "width"> { - weather: RouterOutputs["widget"]["weather"]["atLocation"]; -} - -function Forecast({ weather, options, width }: ForecastProps) { +function Forecast({ weather, options }: WeatherProps) { return ( - - {weather.daily.time - .slice(0, Math.min(options.forecastDayCount, width / (width < 300 ? 64 : 92))) - .map((time, index) => ( - - - - {new Date(time).getDate().toString().padStart(2, "0")} - - - - {getPreferredUnit(weather.daily.temperature_2m_max[index]!, options.isFormatFahrenheit)} - - - {getPreferredUnit(weather.daily.temperature_2m_min[index]!, options.isFormatFahrenheit)} - - - - ))} - + + {weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => ( + + + + {dayjs(dayWeather.time).format("dd")} + + {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)} + + + + + + + ))} + ); } -const getPreferredUnit = (value: number, isFahrenheit = false): string => - isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`; +const getPreferredUnit = (value?: number, isFahrenheit = false): string => + value ? (isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`) : "?"; diff --git a/packages/widgets/src/weather/icon.tsx b/packages/widgets/src/weather/icon.tsx index 71d9dc87b..25a25b34d 100644 --- a/packages/widgets/src/weather/icon.tsx +++ b/packages/widgets/src/weather/icon.tsx @@ -1,4 +1,4 @@ -import { Box, Tooltip } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; import { IconCloud, IconCloudFog, @@ -9,6 +9,7 @@ import { IconSnowflake, IconSun, } from "@tabler/icons-react"; +import dayjs from "dayjs"; import type { TranslationObject } from "@homarr/translation"; import { useScopedI18n } from "@homarr/translation/client"; @@ -16,26 +17,64 @@ import type { TablerIcon } from "@homarr/ui"; interface WeatherIconProps { code: number; - size?: number; + size?: string | number; } /** * Icon which should be displayed when specific code is defined * @param code weather code from api - * @returns weather tile component + * @param size size of the icon, accepts relative sizes too + * @returns Icon corresponding to the weather code */ export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => { + const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather; + + return ; +}; + +interface WeatherDescriptionProps { + weatherOnly?: boolean; + time?: string; + weatherCode: number; + maxTemp?: string; + minTemp?: string; +} + +/** + * Description Dropdown for a given set of parameters + * @param time date that can be formatted by dayjs + * @param weatherCode weather code from api + * @param maxTemp preformatted string for max temperature + * @param minTemp preformatted string for min temperature + * @returns Content for a HoverCard dropdown presenting weather information + */ +export const WeatherDescription = ({ weatherOnly, time, weatherCode, maxTemp, minTemp }: WeatherDescriptionProps) => { const t = useScopedI18n("widget.weather"); + const tCommon = useScopedI18n("common"); + + const { name } = weatherDefinitions.find((definition) => definition.codes.includes(weatherCode)) ?? unknownWeather; - const { icon: Icon, name } = - weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather; + if (weatherOnly) { + return {t(`kind.${name}`)}; + } return ( - - - - - + + {dayjs(time).format("dddd MMMM D YYYY")} + {t(`kind.${name}`)} + + {tCommon("rtl", { + value: tCommon("information.max"), + symbol: tCommon("symbols.colon"), + }) + maxTemp} + + + {tCommon("rtl", { + value: tCommon("information.min"), + symbol: tCommon("symbols.colon"), + }) + minTemp} + + ); };