feat: weather widget scalable (#574)
* refactor: Make weather widget scalable * fix: formatting * fix: map key again * fix: null assertions
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -8,8 +8,9 @@
|
|||||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||||
"prettier.configPath": "./tooling/prettier/index.mjs",
|
"prettier.configPath": "./tooling/prettier/index.mjs",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"superjson",
|
"cqmin",
|
||||||
"homarr",
|
"homarr",
|
||||||
|
"superjson",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami"
|
"Umami"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -65,14 +65,13 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
|||||||
gs-h={item.height}
|
gs-h={item.height}
|
||||||
gs-min-w={1}
|
gs-min-w={1}
|
||||||
gs-min-h={1}
|
gs-min-h={1}
|
||||||
gs-max-w={4}
|
|
||||||
gs-max-h={4}
|
|
||||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
classes.itemCard,
|
classes.itemCard,
|
||||||
|
`${item.kind}-wrapper`,
|
||||||
"grid-stack-item-content",
|
"grid-stack-item-content",
|
||||||
item.advancedOptions.customCssClasses.join(" "),
|
item.advancedOptions.customCssClasses.join(" "),
|
||||||
)}
|
)}
|
||||||
@@ -80,6 +79,7 @@ const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
|||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
"--opacity": opacity / 100,
|
"--opacity": opacity / 100,
|
||||||
|
containerType: "size",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
p={0}
|
p={0}
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ import { validation } from "@homarr/validation";
|
|||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const weatherRouter = createTRPCRouter({
|
export const weatherRouter = createTRPCRouter({
|
||||||
atLocation: publicProcedure
|
atLocation: publicProcedure.input(validation.widget.weather.atLocationInput).query(async ({ input }) => {
|
||||||
.input(validation.widget.weather.atLocationInput)
|
const res = await fetch(
|
||||||
.output(validation.widget.weather.atLocationOutput)
|
`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`,
|
||||||
.query(async ({ input }) => {
|
);
|
||||||
const res = await fetch(
|
const json: unknown = await res.json();
|
||||||
`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 weather = await validation.widget.weather.atLocationOutput.parseAsync(json);
|
||||||
);
|
return {
|
||||||
return res.json();
|
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 }],
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -429,6 +429,9 @@ export default {
|
|||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
rtl: "{value}{symbol}",
|
rtl: "{value}{symbol}",
|
||||||
|
symbols: {
|
||||||
|
colon: ": ",
|
||||||
|
},
|
||||||
action: {
|
action: {
|
||||||
add: "Add",
|
add: "Add",
|
||||||
apply: "Apply",
|
apply: "Apply",
|
||||||
@@ -452,6 +455,10 @@ export default {
|
|||||||
iconPicker: {
|
iconPicker: {
|
||||||
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||||
},
|
},
|
||||||
|
information: {
|
||||||
|
min: "Min",
|
||||||
|
max: "Max",
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
create: {
|
create: {
|
||||||
success: "Creation successful",
|
success: "Creation successful",
|
||||||
|
|||||||
@@ -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 { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
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 type { WidgetComponentProps } from "../definition";
|
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(
|
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
latitude: options.location.latitude,
|
latitude: options.location.latitude,
|
||||||
@@ -21,113 +23,123 @@ export default function WeatherWidget({ options, width }: WidgetComponentProps<"
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack w="100%" h="100%" justify="space-around" gap={0} align="center">
|
<Stack align="center" justify="center" gap="0" w="100%" h="100%">
|
||||||
<WeeklyForecast weather={weather} width={width} options={options} shouldHide={!options.hasForecast} />
|
{options.hasForecast ? (
|
||||||
<DailyWeather weather={weather} width={width} options={options} shouldHide={options.hasForecast} />
|
<WeeklyForecast weather={weather} options={options} />
|
||||||
|
) : (
|
||||||
|
<DailyWeather weather={weather} options={options} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DailyWeatherProps extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
|
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
|
||||||
shouldHide: boolean;
|
|
||||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DailyWeather = ({ shouldHide, width, options, weather }: DailyWeatherProps) => {
|
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
||||||
if (shouldHide) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex
|
<Group className="weather-day-group" gap="1cqmin">
|
||||||
align="center"
|
<HoverCard>
|
||||||
gap={width < 120 ? "0.25rem" : "xs"}
|
<HoverCard.Target>
|
||||||
justify={"center"}
|
<Box>
|
||||||
direction={width < 200 ? "column" : "row"}
|
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
|
||||||
>
|
</Box>
|
||||||
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
|
</HoverCard.Target>
|
||||||
<Title order={2}>{getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)}</Title>
|
<HoverCard.Dropdown>
|
||||||
</Flex>
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
|
</HoverCard.Dropdown>
|
||||||
{width > 200 && (
|
</HoverCard>
|
||||||
<Group wrap="nowrap" gap="xs">
|
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
||||||
<IconArrowUpRight />
|
</Group>
|
||||||
{getPreferredUnit(weather.daily.temperature_2m_max[0]!, options.isFormatFahrenheit)}
|
<Space h="1cqmin" />
|
||||||
<IconArrowDownRight />
|
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
||||||
{getPreferredUnit(weather.daily.temperature_2m_min[0]!, options.isFormatFahrenheit)}
|
<IconArrowUpRight size="12.5cqmin" />
|
||||||
</Group>
|
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
|
||||||
)}
|
<Space w="2.5cqmin" />
|
||||||
|
<IconArrowDownRight size="12.5cqmin" />
|
||||||
|
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
|
||||||
|
</Group>
|
||||||
{options.showCity && (
|
{options.showCity && (
|
||||||
<Group wrap="nowrap" gap={4} align="center">
|
<>
|
||||||
<IconMapPin height={15} width={15} />
|
<Space h="5cqmin" />
|
||||||
<Text style={{ whiteSpace: "nowrap" }}>{options.location.name}</Text>
|
<Group className="weather-city-group" wrap="nowrap" gap="1cqmin">
|
||||||
</Group>
|
<IconMapPin size="12.5cqmin" />
|
||||||
)}
|
<Text size="12.5cqmin" style={{ whiteSpace: "nowrap" }}>
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WeeklyForecastProps extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
|
|
||||||
shouldHide: boolean;
|
|
||||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
|
||||||
}
|
|
||||||
|
|
||||||
const WeeklyForecast = ({ shouldHide, width, options, weather }: WeeklyForecastProps) => {
|
|
||||||
if (shouldHide) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Flex align="center" gap={width < 120 ? "0.25rem" : "xs"} justify="center" direction="row">
|
|
||||||
{options.showCity && (
|
|
||||||
<Group wrap="nowrap" gap="xs" align="center">
|
|
||||||
<IconMapPin color="blue" size={30} />
|
|
||||||
<Text size="xl" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{options.location.name}
|
{options.location.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</>
|
||||||
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
|
)}
|
||||||
<Title order={2} c={weather.current_weather.temperature > 20 ? "red" : "blue"}>
|
|
||||||
{getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)}
|
|
||||||
</Title>
|
|
||||||
</Flex>
|
|
||||||
<Forecast weather={weather} options={options} width={width} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ForecastProps extends Pick<WidgetComponentProps<"weather">, "options" | "width"> {
|
const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
||||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function Forecast({ weather, options, width }: ForecastProps) {
|
|
||||||
return (
|
return (
|
||||||
<Flex align="center" direction="row" justify="space-between" w="100%">
|
<>
|
||||||
{weather.daily.time
|
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="5cqmin">
|
||||||
.slice(0, Math.min(options.forecastDayCount, width / (width < 300 ? 64 : 92)))
|
{options.showCity && (
|
||||||
.map((time, index) => (
|
<>
|
||||||
<Card key={time}>
|
<IconMapPin size="20cqmin" />
|
||||||
<Flex direction="column" align="center">
|
<Text size="15cqmin" style={{ whiteSpace: "nowrap" }}>
|
||||||
<Text fw={700} lh="1.25rem">
|
{options.location.name}
|
||||||
{new Date(time).getDate().toString().padStart(2, "0")}
|
</Text>
|
||||||
</Text>
|
<Space w="20cqmin" />
|
||||||
<WeatherIcon size={width < 300 ? 20 : 50} code={weather.daily.weathercode[index]!} />
|
</>
|
||||||
<Text fz={width < 300 ? "xs" : "sm"} lh="1rem">
|
)}
|
||||||
{getPreferredUnit(weather.daily.temperature_2m_max[index]!, options.isFormatFahrenheit)}
|
<HoverCard>
|
||||||
</Text>
|
<HoverCard.Target>
|
||||||
<Text fz={width < 300 ? "xs" : "sm"} lh="1rem" c="grey">
|
<Box>
|
||||||
{getPreferredUnit(weather.daily.temperature_2m_min[index]!, options.isFormatFahrenheit)}
|
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
|
||||||
</Text>
|
</Box>
|
||||||
</Flex>
|
</HoverCard.Target>
|
||||||
</Card>
|
<HoverCard.Dropdown>
|
||||||
))}
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</Flex>
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Space h="2.5cqmin" />
|
||||||
|
<Forecast weather={weather} options={options} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Forecast({ weather, options }: WeatherProps) {
|
||||||
|
return (
|
||||||
|
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin">
|
||||||
|
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
|
||||||
|
<HoverCard key={dayWeather.time} withArrow shadow="md">
|
||||||
|
<HoverCard.Target>
|
||||||
|
<Stack
|
||||||
|
className={combineClasses(
|
||||||
|
"weather-forecast-day-stack",
|
||||||
|
`weather-forecast-day${index}`,
|
||||||
|
`weather-forecast-weekday${dayjs(dayWeather.time).day()}`,
|
||||||
|
)}
|
||||||
|
gap="0"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<Text fz="10cqmin">{dayjs(dayWeather.time).format("dd")}</Text>
|
||||||
|
<WeatherIcon size="15cqmin" code={dayWeather.weatherCode} />
|
||||||
|
<Text fz="10cqmin">{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}</Text>
|
||||||
|
</Stack>
|
||||||
|
</HoverCard.Target>
|
||||||
|
<HoverCard.Dropdown>
|
||||||
|
<WeatherDescription
|
||||||
|
time={dayWeather.time}
|
||||||
|
weatherCode={dayWeather.weatherCode}
|
||||||
|
maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}
|
||||||
|
minTemp={getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit)}
|
||||||
|
/>
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPreferredUnit = (value: number, isFahrenheit = false): string =>
|
const getPreferredUnit = (value?: number, isFahrenheit = false): string =>
|
||||||
isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
value ? (isFahrenheit ? `${(value * (9 / 5) + 32).toFixed(1)}°F` : `${value.toFixed(1)}°C`) : "?";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Tooltip } from "@mantine/core";
|
import { Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconCloud,
|
IconCloud,
|
||||||
IconCloudFog,
|
IconCloudFog,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconSnowflake,
|
IconSnowflake,
|
||||||
IconSun,
|
IconSun,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { TranslationObject } from "@homarr/translation";
|
import type { TranslationObject } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -16,26 +17,64 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
|
|
||||||
interface WeatherIconProps {
|
interface WeatherIconProps {
|
||||||
code: number;
|
code: number;
|
||||||
size?: number;
|
size?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon which should be displayed when specific code is defined
|
* Icon which should be displayed when specific code is defined
|
||||||
* @param code weather code from api
|
* @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) => {
|
export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
|
||||||
|
|
||||||
const { icon: Icon, name } =
|
return <Icon style={{ float: "left" }} size={size} />;
|
||||||
weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (weatherOnly) {
|
||||||
|
return <Text fz="16px">{t(`kind.${name}`)}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip withinPortal withArrow label={t(`kind.${name}`)}>
|
<Stack align="center" gap="0">
|
||||||
<Box>
|
<Text fz="24px">{dayjs(time).format("dddd MMMM D YYYY")}</Text>
|
||||||
<Icon style={{ float: "left" }} size={size} />
|
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
||||||
</Box>
|
<Text fz="16px">
|
||||||
</Tooltip>
|
{tCommon("rtl", {
|
||||||
|
value: tCommon("information.max"),
|
||||||
|
symbol: tCommon("symbols.colon"),
|
||||||
|
}) + maxTemp}
|
||||||
|
</Text>
|
||||||
|
<Text fz="16px">
|
||||||
|
{tCommon("rtl", {
|
||||||
|
value: tCommon("information.min"),
|
||||||
|
symbol: tCommon("symbols.colon"),
|
||||||
|
}) + minTemp}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user