feat: add weather widget (#286)
* feat: add nestjs replacement, remove nestjs * feat: add weather widget * fix: lock issue * fix: format issue * fix: deepsource issues * fix: change timezone to auto
This commit is contained in:
@@ -1,7 +1,209 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
IconArrowDownRight,
|
||||
IconArrowUpRight,
|
||||
IconMapPin,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { WeatherIcon } from "./icon";
|
||||
|
||||
export default function WeatherWidget({
|
||||
options: _options,
|
||||
options,
|
||||
width,
|
||||
}: WidgetComponentProps<"weather">) {
|
||||
return <div>WEATHER</div>;
|
||||
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
|
||||
{
|
||||
latitude: options.location.latitude,
|
||||
longitude: options.location.longitude,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface DailyWeatherProps
|
||||
extends Pick<WidgetComponentProps<"weather">, "width" | "options"> {
|
||||
shouldHide: boolean;
|
||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||
}
|
||||
|
||||
const DailyWeather = ({
|
||||
shouldHide,
|
||||
width,
|
||||
options,
|
||||
weather,
|
||||
}: DailyWeatherProps) => {
|
||||
if (shouldHide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{options.showCity && (
|
||||
<Group wrap="nowrap" gap={4} align="center">
|
||||
<IconMapPin height={15} width={15} />
|
||||
<Text 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;
|
||||
}
|
||||
|
||||
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}
|
||||
</Text>
|
||||
</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"> {
|
||||
weather: RouterOutputs["widget"]["weather"]["atLocation"];
|
||||
}
|
||||
|
||||
function Forecast({ weather, options, width }: ForecastProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const getPreferredUnit = (value: number, isFahrenheit = false): string =>
|
||||
isFahrenheit
|
||||
? `${(value * (9 / 5) + 32).toFixed(1)}°F`
|
||||
: `${value.toFixed(1)}°C`;
|
||||
|
||||
81
packages/widgets/src/weather/icon.tsx
Normal file
81
packages/widgets/src/weather/icon.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import {
|
||||
Box,
|
||||
IconCloud,
|
||||
IconCloudFog,
|
||||
IconCloudRain,
|
||||
IconCloudSnow,
|
||||
IconCloudStorm,
|
||||
IconQuestionMark,
|
||||
IconSnowflake,
|
||||
IconSun,
|
||||
Tooltip,
|
||||
} from "@homarr/ui";
|
||||
|
||||
interface WeatherIconProps {
|
||||
code: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon which should be displayed when specific code is defined
|
||||
* @param code weather code from api
|
||||
* @returns weather tile component
|
||||
*/
|
||||
export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
|
||||
const t = useScopedI18n("widget.weather");
|
||||
|
||||
const { icon: Icon, name } =
|
||||
weatherDefinitions.find((definition) => definition.codes.includes(code)) ??
|
||||
unknownWeather;
|
||||
|
||||
return (
|
||||
<Tooltip withinPortal withArrow label={t(`kind.${name}`)}>
|
||||
<Box>
|
||||
<Icon style={{ float: "left" }} size={size} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface WeatherDefinitionType {
|
||||
icon: TablerIcon;
|
||||
name: keyof TranslationObject["widget"]["weather"]["kind"];
|
||||
codes: number[];
|
||||
}
|
||||
|
||||
// 0 Clear sky
|
||||
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
|
||||
// 45, 48 Fog and depositing rime fog
|
||||
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
|
||||
// 56, 57 Freezing Drizzle: Light and dense intensity
|
||||
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
|
||||
// 66, 67 Freezing Rain: Light and heavy intensity
|
||||
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
|
||||
// 77 Snow grains
|
||||
// 80, 81, 82 Rain showers: Slight, moderate, and violent
|
||||
// 85, 86Snow showers slight and heavy
|
||||
// 95 *Thunderstorm: Slight or moderate
|
||||
// 96, 99 *Thunderstorm with slight and heavy hail
|
||||
const weatherDefinitions: WeatherDefinitionType[] = [
|
||||
{ icon: IconSun, name: "clear", codes: [0] },
|
||||
{ icon: IconCloud, name: "mainlyClear", codes: [1, 2, 3] },
|
||||
{ icon: IconCloudFog, name: "fog", codes: [45, 48] },
|
||||
{ icon: IconCloud, name: "drizzle", codes: [51, 53, 55] },
|
||||
{ icon: IconSnowflake, name: "freezingDrizzle", codes: [56, 57] },
|
||||
{ icon: IconCloudRain, name: "rain", codes: [61, 63, 65] },
|
||||
{ icon: IconCloudRain, name: "freezingRain", codes: [66, 67] },
|
||||
{ icon: IconCloudSnow, name: "snowFall", codes: [71, 73, 75] },
|
||||
{ icon: IconCloudSnow, name: "snowGrains", codes: [77] },
|
||||
{ icon: IconCloudRain, name: "rainShowers", codes: [80, 81, 82] },
|
||||
{ icon: IconCloudSnow, name: "snowShowers", codes: [85, 86] },
|
||||
{ icon: IconCloudStorm, name: "thunderstorm", codes: [95] },
|
||||
{ icon: IconCloudStorm, name: "thunderstormWithHail", codes: [96, 99] },
|
||||
];
|
||||
|
||||
const unknownWeather: Omit<WeatherDefinitionType, "codes"> = {
|
||||
icon: IconQuestionMark,
|
||||
name: "unknown",
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconCloud } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
@@ -7,9 +8,32 @@ export const { definition, componentLoader } = createWidgetDefinition(
|
||||
"weather",
|
||||
{
|
||||
icon: IconCloud,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
location: factory.location(),
|
||||
showCity: factory.switch(),
|
||||
})),
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
isFormatFahrenheit: factory.switch(),
|
||||
location: factory.location({
|
||||
defaultValue: {
|
||||
name: "Paris",
|
||||
latitude: 48.85341,
|
||||
longitude: 2.3488,
|
||||
},
|
||||
}),
|
||||
showCity: factory.switch(),
|
||||
hasForecast: factory.switch(),
|
||||
forecastDayCount: factory.slider({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(7),
|
||||
step: 1,
|
||||
withDescription: true,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
forecastDayCount: {
|
||||
shouldHide({ hasForecast }) {
|
||||
return !hasForecast;
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
).withDynamicImport(() => import("./component"));
|
||||
|
||||
Reference in New Issue
Block a user