Skip to content

Commit

Permalink
feat: weather widget scalable (#574)
Browse files Browse the repository at this point in the history
* refactor: Make weather widget scalable

* fix: formatting

* fix: map key again

* fix: null assertions
  • Loading branch information
SeDemal authored Jun 5, 2024
1 parent 2623708 commit cfd8b32
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 106 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [
"superjson",
"cqmin",
"homarr",
"superjson",
"trpc",
"Umami"
]
Expand Down
4 changes: 2 additions & 2 deletions apps/nextjs/src/components/board/sections/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,21 @@ 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<HTMLDivElement>}
>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
withBorder
styles={{
root: {
"--opacity": opacity / 100,
containerType: "size",
},
}}
p={0}
Expand Down
27 changes: 18 additions & 9 deletions packages/api/src/router/widgets/weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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&current_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&current_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 }],
};
}),
});
7 changes: 7 additions & 0 deletions packages/translation/src/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ export default {
},
common: {
rtl: "{value}{symbol}",
symbols: {
colon: ": ",
},
action: {
add: "Add",
apply: "Apply",
Expand All @@ -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",
Expand Down
180 changes: 96 additions & 84 deletions packages/widgets/src/weather/component.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,113 +23,123 @@ export default function WeatherWidget({ options, width }: WidgetComponentProps<"
);

return (
<Stack w="100%" h="100%" justify="space-around" gap={0} align="center">
<WeeklyForecast weather={weather} width={width} options={options} shouldHide={!options.hasForecast} />
<DailyWeather weather={weather} width={width} options={options} shouldHide={options.hasForecast} />
<Stack align="center" justify="center" gap="0" w="100%" h="100%">
{options.hasForecast ? (
<WeeklyForecast weather={weather} options={options} />
) : (
<DailyWeather weather={weather} options={options} />
)}
</Stack>
);
}

interface DailyWeatherProps extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
shouldHide: boolean;
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
weather: RouterOutputs["widget"]["weather"]["atLocation"];
}

const DailyWeather = ({ shouldHide, width, options, weather }: DailyWeatherProps) => {
if (shouldHide) {
return null;
}

const DailyWeather = ({ options, weather }: WeatherProps) => {
return (
<>
<Flex
align="center"
gap={width < 120 ? "0.25rem" : "xs"}
justify={"center"}
direction={width < 200 ? "column" : "row"}
>
<WeatherIcon size={width < 300 ? 30 : 50} code={weather.current_weather.weathercode} />
<Title order={2}>{getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)}</Title>
</Flex>

{width > 200 && (
<Group wrap="nowrap" gap="xs">
<IconArrowUpRight />
{getPreferredUnit(weather.daily.temperature_2m_max[0]!, options.isFormatFahrenheit)}
<IconArrowDownRight />
{getPreferredUnit(weather.daily.temperature_2m_min[0]!, options.isFormatFahrenheit)}
</Group>
)}

<Group className="weather-day-group" gap="1cqmin">
<HoverCard>
<HoverCard.Target>
<Box>
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown>
</HoverCard>
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
</Group>
<Space h="1cqmin" />
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
<IconArrowUpRight size="12.5cqmin" />
<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 && (
<Group wrap="nowrap" gap={4} align="center">
<IconMapPin height={15} width={15} />
<Text style={{ whiteSpace: "nowrap" }}>{options.location.name}</Text>
</Group>
<>
<Space h="5cqmin" />
<Group className="weather-city-group" wrap="nowrap" gap="1cqmin">
<IconMapPin size="12.5cqmin" />
<Text size="12.5cqmin" style={{ whiteSpace: "nowrap" }}>
{options.location.name}
</Text>
</Group>
</>
)}
</>
);
};

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;
}

const WeeklyForecast = ({ options, weather }: WeatherProps) => {
return (
<>
<Flex align="center" gap={width < 120 ? "0.25rem" : "xs"} justify="center" direction="row">
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="5cqmin">
{options.showCity && (
<Group wrap="nowrap" gap="xs" align="center">
<IconMapPin color="blue" size={30} />
<Text size="xl" style={{ whiteSpace: "nowrap" }}>
<>
<IconMapPin size="20cqmin" />
<Text size="15cqmin" style={{ whiteSpace: "nowrap" }}>
{options.location.name}
</Text>
</Group>
<Space w="20cqmin" />
</>
)}
<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} />
<HoverCard>
<HoverCard.Target>
<Box>
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown>
</HoverCard>
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
</Group>
<Space h="2.5cqmin" />
<Forecast weather={weather} options={options} />
</>
);
};

interface ForecastProps extends Pick<WidgetComponentProps<"weather">, "options" | "width"> {
weather: RouterOutputs["widget"]["weather"]["atLocation"];
}

function Forecast({ weather, options, width }: ForecastProps) {
function Forecast({ weather, options }: WeatherProps) {
return (
<Flex align="center" direction="row" justify="space-between" w="100%">
{weather.daily.time
.slice(0, Math.min(options.forecastDayCount, width / (width < 300 ? 64 : 92)))
.map((time, index) => (
<Card key={time}>
<Flex direction="column" align="center">
<Text fw={700} lh="1.25rem">
{new Date(time).getDate().toString().padStart(2, "0")}
</Text>
<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)}
</Text>
<Text fz={width < 300 ? "xs" : "sm"} lh="1rem" c="grey">
{getPreferredUnit(weather.daily.temperature_2m_min[index]!, options.isFormatFahrenheit)}
</Text>
</Flex>
</Card>
))}
</Flex>
<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 =>
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`) : "?";
59 changes: 49 additions & 10 deletions packages/widgets/src/weather/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Tooltip } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import {
IconCloud,
IconCloudFog,
Expand All @@ -9,33 +9,72 @@ import {
IconSnowflake,
IconSun,
} from "@tabler/icons-react";
import dayjs from "dayjs";

import type { TranslationObject } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
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 <Icon style={{ float: "left" }} size={size} />;
};

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 <Text fz="16px">{t(`kind.${name}`)}</Text>;
}

return (
<Tooltip withinPortal withArrow label={t(`kind.${name}`)}>
<Box>
<Icon style={{ float: "left" }} size={size} />
</Box>
</Tooltip>
<Stack align="center" gap="0">
<Text fz="24px">{dayjs(time).format("dddd MMMM D YYYY")}</Text>
<Text fz="16px">{t(`kind.${name}`)}</Text>
<Text fz="16px">
{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>
);
};

Expand Down

0 comments on commit cfd8b32

Please sign in to comment.