Improve location selection for weather

This commit is contained in:
Meier Lukas
2023-06-10 23:53:04 +02:00
parent a5f3d48a71
commit d00a317202
10 changed files with 363 additions and 112 deletions

View File

@@ -0,0 +1,34 @@
{
"form": {
"field": {
"query": "City / postal code",
"latitude": "Latitude",
"longitude": "Longitude"
},
"button": {
"search": {
"label": "Search",
"disabledTooltip": "Please choose a city / postal code first"
}
},
"empty": "Unknown location"
},
"modal": {
"title": "Choose a location",
"table": {
"header": {
"city": "City",
"country": "Country",
"coordinates": "Coordinates",
"population": "Population"
},
"action": {
"select": "Select {{city}}, {{countryCode}}"
},
"population": {
"fallback": "Unknown",
"count": "{{count}} people"
}
}
}
}

View File

@@ -0,0 +1,236 @@
import {
Card,
Stack,
Text,
Title,
Group,
TextInput,
Button,
NumberInput,
Modal,
Table,
Tooltip,
ActionIcon,
Loader,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconListSearch, IconClick } from '@tabler/icons-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IntegrationOptionsValueType } from '../WidgetsEditModal';
import { City } from '~/server/api/routers/weather';
import { api } from '~/utils/api';
type LocationSelectionProps = {
widgetId: string;
propName: string;
value: any;
handleChange: (key: string, value: IntegrationOptionsValueType) => void;
};
export const LocationSelection = ({
widgetId,
propName: key,
value,
handleChange,
}: LocationSelectionProps) => {
const { t } = useTranslation('widgets/location');
const [query, setQuery] = useState(value.name ?? '');
const [opened, { open, close }] = useDisclosure(false);
const selectionEnabled = query.length > 1;
const EMPTY_LOCATION = t('form.empty');
const onCitySelected = (city: City) => {
close();
handleChange(key, {
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
});
setQuery(city.name);
};
return (
<>
<Card>
<Stack spacing="xs">
<Title order={5}>{t(`modules/${widgetId}:descriptor.settings.${key}.label`)}</Title>
<Group noWrap align="end">
<TextInput
w="100%"
label={t('form.field.query')}
value={query}
onChange={(ev) => {
setQuery(ev.currentTarget.value);
handleChange(key, {
name: ev.currentTarget.value,
longitude: '',
latitude: '',
});
}}
/>
<Tooltip hidden={selectionEnabled} label={t('form.button.search.disabledTooltip')}>
<div>
<Button
disabled={!selectionEnabled}
onClick={() => {
if (selectionEnabled) open();
}}
variant="light"
leftIcon={<IconListSearch size={16} />}
>
{t('form.button.search.label')}
</Button>
</div>
</Tooltip>
</Group>
<Group grow>
<NumberInput
value={value.latitude}
onChange={(v) => {
if (typeof v !== 'number') return;
handleChange(key, {
...value,
name: EMPTY_LOCATION,
latitude: v,
});
setQuery(EMPTY_LOCATION);
}}
precision={5}
label={t('form.field.latitude')}
hideControls
/>
<NumberInput
value={value.longitude}
onChange={(v) => {
if (typeof v !== 'number') return;
handleChange(key, {
...value,
name: EMPTY_LOCATION,
longitude: v,
});
setQuery(EMPTY_LOCATION);
}}
precision={5}
label={t('form.field.longitude')}
hideControls
/>
</Group>
</Stack>
</Card>
<CitySelectModal
opened={opened}
closeModal={close}
query={query}
onCitySelected={onCitySelected}
/>
</>
);
};
type CitySelectModalProps = {
opened: boolean;
closeModal: () => void;
query: string;
onCitySelected: (location: City) => void;
};
const CitySelectModal = ({ opened, closeModal, query, onCitySelected }: CitySelectModalProps) => {
const { t } = useTranslation('widgets/location');
const { isLoading, data } = api.weather.findCity.useQuery(
{ query },
{
enabled: opened,
refetchOnWindowFocus: false,
refetchOnMount: false,
}
);
return (
<Modal
title={
<Title order={4}>
{t('modal.title')} - {query}
</Title>
}
size="xl"
opened={opened}
onClose={closeModal}
zIndex={250}
>
<Stack>
<Table striped>
<thead>
<tr>
<th style={{ width: '70%' }}>{t('modal.table.header.city')}</th>
<th style={{ width: '50%' }}>{t('modal.table.header.country')}</th>
<th>{t('modal.table.header.coordinates')}</th>
<th>{t('modal.table.header.population')}</th>
<th style={{ width: 40 }} />
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={5}>
<Group position="center">
<Loader />
</Group>
</td>
</tr>
)}
{data?.results.map((city) => (
<tr key={city.id}>
<td>
<Text style={{ whiteSpace: 'nowrap' }}>{city.name}</Text>
</td>
<td>
<Text style={{ whiteSpace: 'nowrap' }}>{city.country}</Text>
</td>
<td>
<Text style={{ whiteSpace: 'nowrap' }}>
{city.latitude}, {city.longitude}
</Text>
</td>
<td>
{city.population ? (
<Text style={{ whiteSpace: 'nowrap' }}>
{t('modal.population.count', { count: city.population })}
</Text>
) : (
<Text color="dimmed"> {t('modal.population.fallback')}</Text>
)}
</td>
<td>
<Tooltip
label={t('modal.table.action.select', {
city: city.name,
countryCode: city.country_code,
})}
>
<ActionIcon
color="red"
variant="subtle"
onClick={() => {
onCitySelected(city);
}}
>
<IconClick size={16} />
</ActionIcon>
</Tooltip>
</td>
</tr>
))}
</tbody>
</Table>
<Group position="right">
<Button variant="light" onClick={() => closeModal()}>
{t('common:cancel')}
</Button>
</Group>
</Stack>
</Modal>
);
};

View File

@@ -26,6 +26,7 @@ import Widgets from '../../../../widgets';
import type { IDraggableListInputValue, IWidgetOptionValue } from '../../../../widgets/widgets';
import { IWidget } from '../../../../widgets/widgets';
import { DraggableList } from './Inputs/DraggableList';
import { LocationSelection } from './Inputs/LocationSelection';
import { StaticDraggableList } from './Inputs/StaticDraggableList';
export type WidgetEditModalInnerProps = {
@@ -35,7 +36,7 @@ export type WidgetEditModalInnerProps = {
widgetOptions: IWidget<string, any>['properties'];
};
type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
export type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
export const WidgetsEditModal = ({
context,
@@ -200,6 +201,16 @@ const WidgetOptionTypeSwitch: FC<{
/>
</Stack>
);
case 'location':
return (
<LocationSelection
propName={key}
value={value}
handleChange={handleChange}
widgetId={widgetId}
/>
);
case 'draggable-list':
/* eslint-disable no-case-declarations */
const typedVal = value as IDraggableListInputValue['defaultValue'];

View File

@@ -12,6 +12,7 @@ import { mediaServerRouter } from './routers/media-server';
import { overseerrRouter } from './routers/overseerr';
import { usenetRouter } from './routers/usenet/router';
import { calendarRouter } from './routers/calendar';
import { weatherRouter } from './routers/weather';
/**
* This is the primary router for your server.
@@ -32,6 +33,7 @@ export const rootRouter = createTRPCRouter({
overseerr: overseerrRouter,
usenet: usenetRouter,
calendar: calendarRouter,
weather: weatherRouter,
});
// export type definition of API

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';
const citySchema = z.object({
id: z.number(),
name: z.string(),
country: z.string(),
country_code: z.string(),
latitude: z.number(),
longitude: z.number(),
population: z.number().optional(),
});
const weatherSchema = z.object({
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
}),
daily: z.object({
temperature_2m_max: z.array(z.number()),
temperature_2m_min: z.array(z.number()),
}),
});
export const weatherRouter = createTRPCRouter({
findCity: publicProcedure
.input(
z.object({
query: z.string().min(2),
})
)
.output(
z.object({
results: z.array(citySchema),
})
)
.query(async ({ input }) => {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
return res.json();
}),
at: publicProcedure
.input(
z.object({
longitude: z.number(),
latitude: z.number(),
})
)
.output(weatherSchema)
.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=Europe%2FLondon`
);
return res.json();
}),
});
export type City = z.infer<typeof citySchema>;
export type Weather = z.infer<typeof weatherSchema>;

View File

@@ -44,6 +44,7 @@ export const dashboardNamespaces = [
'modules/bookmark',
'widgets/error-boundary',
'widgets/draggable-list',
'widgets/location',
];
export const loginNamespaces = ['authentication/login'];

View File

@@ -1,9 +1,9 @@
import { Center, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconArrowDownRight, IconArrowUpRight, IconCloudRain } from '@tabler/icons-react';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { useWeatherForCity } from './useWeatherForCity';
import { WeatherIcon } from './WeatherIcon';
const definition = defineWidget({
@@ -15,8 +15,12 @@ const definition = defineWidget({
defaultValue: false,
},
location: {
type: 'text',
defaultValue: 'Paris',
type: 'location',
defaultValue: {
name: 'Paris',
latitude: 48.85341,
longitude: 2.3488,
},
},
},
gridstack: {
@@ -35,8 +39,8 @@ interface WeatherTileProps {
}
function WeatherTile({ widget }: WeatherTileProps) {
const { data: weather, isLoading, isError } = useWeatherForCity(widget.properties.location);
const { width, height, ref } = useElementSize();
const { data: weather, isLoading, isError } = api.weather.at.useQuery(widget.properties.location);
const { width, ref } = useElementSize();
if (isLoading) {
return (
@@ -77,10 +81,10 @@ function WeatherTile({ widget }: WeatherTileProps) {
style={{ height: '100%', width: '100%' }}
>
<Group align="center" position="center" spacing="xs">
<WeatherIcon code={weather!.current_weather.weathercode} />
<WeatherIcon code={weather.current_weather.weathercode} />
<Title>
{getPerferedUnit(
weather!.current_weather.temperature,
weather.current_weather.temperature,
widget.properties.displayInFahrenheit
)}
</Title>
@@ -89,12 +93,12 @@ function WeatherTile({ widget }: WeatherTileProps) {
<Group noWrap spacing="xs">
<IconArrowUpRight />
{getPerferedUnit(
weather!.daily.temperature_2m_max[0],
weather.daily.temperature_2m_max[0],
widget.properties.displayInFahrenheit
)}
<IconArrowDownRight />
{getPerferedUnit(
weather!.daily.temperature_2m_min[0],
weather.daily.temperature_2m_min[0],
widget.properties.displayInFahrenheit
)}
</Group>

View File

@@ -1,41 +0,0 @@
// To parse this data:
//
// import { Convert, WeatherResponse } from "./file";
//
// const weatherResponse = Convert.toWeatherResponse(json);
//
// These functions will throw an error if the JSON doesn't
// match the expected interface, even if the JSON is valid.
export interface WeatherResponse {
current_weather: CurrentWeather;
utc_offset_seconds: number;
latitude: number;
elevation: number;
longitude: number;
generationtime_ms: number;
daily_units: DailyUnits;
daily: Daily;
}
export interface CurrentWeather {
winddirection: number;
windspeed: number;
time: string;
weathercode: number;
temperature: number;
}
export interface Daily {
temperature_2m_max: number[];
time: Date[];
temperature_2m_min: number[];
weathercode: number[];
}
export interface DailyUnits {
temperature_2m_max: string;
temperature_2m_min: string;
time: string;
weathercode: string;
}

View File

@@ -1,60 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { WeatherResponse } from './types';
/**
* Requests the weather of the specified city
* @param cityName name of the city where the weather should be requested
* @returns weather of specified city
*/
export const useWeatherForCity = (cityName: string) => {
const {
data: city,
isLoading,
isError,
} = useQuery({
queryKey: ['weatherCity', { cityName }],
queryFn: () => fetchCity(cityName),
cacheTime: 1000 * 60 * 60 * 24, // the city is cached for 24 hours
staleTime: Infinity, // the city is never considered stale
});
const weatherQuery = useQuery({
queryKey: ['weather', { cityName }],
queryFn: () => fetchWeather(city?.results[0]),
enabled: Boolean(city),
cacheTime: 1000 * 60 * 60 * 6, // the weather is cached for 6 hours
staleTime: 1000 * 60 * 5, // the weather is considered stale after 5 minutes
});
return {
...weatherQuery,
isLoading: weatherQuery.isLoading || isLoading,
isError: weatherQuery.isError || isError,
};
};
/**
* Requests the coordinates of a city
* @param cityName name of city
* @returns list with all coordinates for citites with specified name
*/
const fetchCity = async (cityName: string) => {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${cityName}`);
return (await res.json()) as { results: Coordinates[] };
};
/**
* Requests the weather of specific coordinates
* @param coordinates of the location the weather should be fetched
* @returns weather of specified coordinates
*/
async function fetchWeather(coordinates?: Coordinates) {
if (!coordinates) return null;
const { longitude, latitude } = coordinates;
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min&current_weather=true&timezone=Europe%2FLondon`
);
// eslint-disable-next-line consistent-return
return (await res.json()) as WeatherResponse;
}
type Coordinates = { latitude: number; longitude: number };

View File

@@ -40,7 +40,8 @@ export type IWidgetOptionValue =
| INumberInputOptionValue
| IDraggableListInputValue
| IDraggableEditableListInputValue<any>
| IMultipleTextInputOptionValue;
| IMultipleTextInputOptionValue
| ILocationOptionValue;
// Interface for data type
interface DataType {
@@ -95,6 +96,11 @@ export type ISliderInputOptionValue = {
inputProps?: Partial<SliderProps>;
};
type ILocationOptionValue = {
type: 'location';
defaultValue: { latitude: number; longitude: number };
};
// will show a sortable list that can have sub settings
export type IDraggableListInputValue = {
type: 'draggable-list';