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:
Meier Lukas
2024-04-13 11:34:55 +02:00
committed by GitHub
parent 7fb0decd5b
commit 80d2d485b8
19 changed files with 762 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ import type { WidgetKind } from "@homarr/definitions";
import { Center } from "@homarr/ui";
import { widgetImports } from "@homarr/widgets";
import { env } from "~/env.mjs";
import { WidgetPreviewPageContent } from "./_content";
interface Props {
@@ -12,7 +13,7 @@ interface Props {
}
export default async function WidgetPreview(props: Props) {
if (!(props.params.kind in widgetImports)) {
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
notFound();
}

View File

@@ -12,6 +12,9 @@ export const env = createEnv({
.optional()
.transform((url) => (url ? `https://${url}` : undefined)),
PORT: z.coerce.number().default(3000),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
},
/**
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
@@ -49,6 +52,7 @@ export const env = createEnv({
DB_NAME: process.env.DB_NAME,
DB_PORT: process.env.DB_PORT,
DB_DRIVER: process.env.DB_DRIVER,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation:

View File

@@ -1,8 +1,10 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { integrationRouter } from "./router/integration";
import { locationRouter } from "./router/location";
import { logRouter } from "./router/log";
import { userRouter } from "./router/user";
import { widgetRouter } from "./router/widgets";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
@@ -10,6 +12,8 @@ export const appRouter = createTRPCRouter({
integration: integrationRouter,
board: boardRouter,
app: innerAppRouter,
widget: widgetRouter,
location: locationRouter,
log: logRouter,
});

View File

@@ -0,0 +1,18 @@
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const locationRouter = createTRPCRouter({
searchCity: publicProcedure
.input(validation.location.searchCity.input)
.output(validation.location.searchCity.output)
.query(async ({ input }) => {
const res = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
);
return (await res.json()) as z.infer<
typeof validation.location.searchCity.output
>;
}),
});

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from "../../trpc";
import { weatherRouter } from "./weather";
export const widgetRouter = createTRPCRouter({
weather: weatherRouter,
});

View File

@@ -0,0 +1,15 @@
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();
}),
});

View File

@@ -376,12 +376,63 @@ export default {
description:
"Displays the current weather information of a set location.",
option: {
isFormatFahrenheit: {
label: "Temperature in Fahrenheit",
},
location: {
label: "Location",
label: "Weather location",
},
showCity: {
label: "Show city",
},
hasForecast: {
label: "Show forecast",
},
forecastDayCount: {
label: "Amount of forecast days",
description:
"When the widget is not wide enough, less days are shown",
},
},
kind: {
clear: "Clear",
mainlyClear: "Mainly clear",
fog: "Fog",
drizzle: "Drizzle",
freezingDrizzle: "Freezing drizzle",
rain: "Rain",
freezingRain: "Freezing rain",
snowFall: "Snow fall",
snowGrains: "Snow grains",
rainShowers: "Rain showers",
snowShowers: "Snow showers",
thunderstorm: "Thunderstorm",
thunderstormWithHail: "Thunderstorm with hail",
unknown: "Unknown",
},
},
common: {
location: {
query: "City / Postal code",
latitude: "Latitude",
longitude: "Longitude",
disabledTooltip: "Please enter a city or postal code",
unknownLocation: "Unknown location",
search: "Search",
table: {
header: {
city: "City",
country: "Country",
coordinates: "Coordinates",
population: "Population",
},
action: {
select: "Select {city}, {countryCode}",
},
population: {
fallback: "Unknown",
},
},
},
},
},

View File

@@ -1,13 +1,17 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { userSchemas } from "./user";
import { widgetSchemas } from "./widgets";
export const validation = {
user: userSchemas,
integration: integrationSchemas,
board: boardSchemas,
app: appSchemas,
widget: widgetSchemas,
location: locationSchemas,
};
export { createSectionSchema, sharedItemSchema } from "./shared";

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
const citySchema = z.object({
id: z.number(),
name: z.string(),
country: z.string().optional(),
country_code: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
population: z.number().optional(),
});
const searchCityInput = z.object({
query: z.string(),
});
const searchCityOutput = z.object({
results: z.array(citySchema),
});
export const locationSchemas = {
searchCity: {
input: searchCityInput,
output: searchCityOutput,
},
};

View File

@@ -0,0 +1,5 @@
import { weatherWidgetSchemas } from "./weather";
export const widgetSchemas = {
weather: weatherWidgetSchemas,
};

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
export const atLocationInput = z.object({
longitude: z.number(),
latitude: z.number(),
});
export const atLocationOutput = z.object({
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
}),
daily: z.object({
time: z.array(z.string()),
weathercode: z.array(z.number()),
temperature_2m_max: z.array(z.number()),
temperature_2m_min: z.array(z.number()),
}),
});
export const weatherWidgetSchemas = {
atLocationInput,
atLocationOutput,
};

View File

@@ -1,5 +1,6 @@
import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
@@ -9,7 +10,7 @@ import { WidgetTextInput } from "./widget-text-input";
const mapping = {
text: WidgetTextInput,
location: () => null,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: () => null,
number: WidgetNumberInput,

View File

@@ -0,0 +1,280 @@
"use client";
import type { ChangeEvent } from "react";
import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import {
ActionIcon,
Anchor,
Button,
Fieldset,
Group,
IconClick,
IconListSearch,
Loader,
NumberInput,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@homarr/ui";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetLocationInput = ({
property,
kind,
}: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const value = form.values.options[property] as OptionLocation;
const selectionEnabled = value.name.length > 1;
const handleChange = form.getInputProps(`options.${property}`)
.onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
handleChange({
name: event.currentTarget.value,
longitude: "",
latitude: "",
});
}, []);
const onLocationSelect = useCallback(
(location: OptionLocation) => {
handleChange(location);
},
[handleChange],
);
const onSearch = useCallback(() => {
if (!selectionEnabled) return;
openModal({
query: value.name,
onLocationSelect,
});
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
const onLatitudeChange = useCallback(
(inputValue: number | string) => {
if (typeof inputValue !== "number") return;
handleChange({
...value,
name: unknownLocation,
latitude: inputValue,
});
},
[value],
);
const onLongitudeChange = useCallback(
(inputValue: number | string) => {
if (typeof inputValue !== "number") return;
handleChange({
...value,
name: unknownLocation,
longitude: inputValue,
});
},
[value],
);
return (
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput
w="100%"
label={tLocation("query")}
value={value.name}
onChange={onQueryChange}
/>
<Tooltip
hidden={selectionEnabled}
label={tLocation("disabledTooltip")}
>
<div>
<Button
disabled={!selectionEnabled}
onClick={onSearch}
variant="light"
leftSection={<IconListSearch size={16} />}
>
{tLocation("search")}
</Button>
</div>
</Tooltip>
</Group>
<Group grow>
<NumberInput
value={value.latitude}
onChange={onLatitudeChange}
decimalScale={5}
label={tLocation("latitude")}
hideControls
/>
<NumberInput
value={value.longitude}
onChange={onLongitudeChange}
decimalScale={5}
label={tLocation("longitude")}
hideControls
/>
</Group>
</Stack>
</Fieldset>
);
};
type LocationOnChange = (
location: Pick<OptionLocation, "name"> & {
latitude: OptionLocation["latitude"] | "";
longitude: OptionLocation["longitude"] | "";
},
) => void;
interface LocationSearchInnerProps {
query: string;
onLocationSelect: (location: OptionLocation) => void;
}
const LocationSearchModal = createModal<LocationSearchInnerProps>(
({ actions, innerProps }) => {
const t = useScopedI18n("widget.common.location.table");
const tCommon = useScopedI18n("common");
const { data, isPending, error } = clientApi.location.searchCity.useQuery({
query: innerProps.query,
});
if (error) {
throw error;
}
return (
<Stack>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th>
<Table.Th style={{ width: "50%" }}>
{t("header.country")}
</Table.Th>
<Table.Th>{t("header.coordinates")}</Table.Th>
<Table.Th>{t("header.population")}</Table.Th>
<Table.Th style={{ width: 40 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isPending && (
<Table.Tr>
<Table.Td colSpan={5}>
<Group justify="center">
<Loader />
</Group>
</Table.Td>
</Table.Tr>
)}
{data?.results.map((city) => (
<LocationSelectTableRow
key={city.id}
city={city}
onLocationSelect={innerProps.onLocationSelect}
closeModal={actions.closeModal}
/>
))}
</Table.Tbody>
</Table>
<Group justify="right">
<Button variant="light" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
</Group>
</Stack>
);
},
).withOptions({
defaultTitle(t) {
return t("widget.common.location.search");
},
size: "xl",
});
interface LocationSearchTableRowProps {
city: RouterOutputs["location"]["searchCity"]["results"][number];
onLocationSelect: (location: OptionLocation) => void;
closeModal: () => void;
}
const LocationSelectTableRow = ({
city,
onLocationSelect,
closeModal,
}: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
});
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
return (
<Table.Tr>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text>
</Table.Td>
<Table.Td>
<Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text>
</Table.Td>
<Table.Td>
<Anchor
target="_blank"
href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}
>
<Text style={{ whiteSpace: "nowrap" }}>
{city.latitude}, {city.longitude}
</Text>
</Anchor>
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>
{formatter.format(city.population)}
</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
</Table.Td>
<Table.Td>
<Tooltip
label={t("action.select", {
city: city.name,
countryCode: city.country_code,
})}
>
<ActionIcon color="red" variant="subtle" onClick={onSelect}>
<IconClick size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
};

View File

@@ -16,10 +16,11 @@ export const WidgetSliderInput = ({
return (
<InputWrapper
label={t("label")}
description={options.withDescription ? t("description") : undefined}
inputWrapperOrder={["label", "input", "description", "error"]}
>
<Slider
label={t("label")}
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}

View File

@@ -88,4 +88,6 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(
</form>
);
},
).withOptions({});
).withOptions({
keepMounted: true,
});

View File

@@ -39,7 +39,7 @@ interface SliderInput extends CommonInput<number> {
step?: number;
}
interface OptLocation {
export interface OptionLocation {
name: string;
latitude: number;
longitude: number;
@@ -90,7 +90,7 @@ const optionsFactory = {
withDescription: input.withDescription ?? false,
validate: input.validate,
}),
location: (input?: CommonInput<OptLocation>) => ({
location: (input?: CommonInput<OptionLocation>) => ({
type: "location" as const,
defaultValue: input?.defaultValue ?? {
name: "",

View File

@@ -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`;

View 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",
};

View File

@@ -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"));