feat: expand utility widgets

This commit is contained in:
ajnart committed 2026-08-20 15:16:26 +02:00
1 parent a2219b72f6
commit 56c2c4dcc0
82 files changed
+7520 -740

No files matched your search

@@ -0,0 +1,101 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { airQualityRequestHandler } from "./air-quality";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeAirQualityResponse = () => {
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
const values = [10, 20, 30];
const fields = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
},
hourly: {
time: times,
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
},
fields,
};
};
beforeEach(() => {
vi.clearAllMocks();
airQualityRequestHandler.invalidateCache();
});
describe("air quality request handler", () => {
it("requests and normalizes the full useful forecast field set", async () => {
const fixture = makeAirQualityResponse();
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.current.pollutants.pm2_5).toBe(12);
expect(first.data.current.pollen.ragweed).toBeNull();
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
expect(first.data.daily).toEqual([
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
]);
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
});
it("rejects unsuccessful Open-Meteo responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
).rejects.toThrow();
});
});
+283
View File
@@ -0,0 +1,283 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const FORECAST_DAYS = 7;
const requestedVariables = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
export const airQualityRequestHandler = createWidgetRequestHandler({
cacheTtlMs: 15 * 60 * 1000,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
async requestAsync(input: { latitude: number; longitude: number }) {
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
url.searchParams.set("latitude", input.latitude.toString());
url.searchParams.set("longitude", input.longitude.toString());
url.searchParams.set("current", requestedVariables.join(","));
url.searchParams.set("hourly", requestedVariables.join(","));
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
url.searchParams.set("timezone", "auto");
const response = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
if (!response.ok) throw new ResponseError(response);
const json: unknown = await response.json();
const airQuality = await atLocationOutput.parseAsync(json);
const hourly = airQuality.hourly.time.map((value, index) => {
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
});
return {
timezone: airQuality.timezone,
utcOffsetSeconds: airQuality.utc_offset_seconds,
current: normalizePoint(
airQuality.current,
undefined,
toUtcInstant(airQuality.current.time, airQuality.timezone),
),
hourly,
daily: getDailyMaximums(airQuality.hourly),
} satisfies AirQuality;
},
});
const nullableNumber = z.number().nullable();
const nullableNumberArray = z.array(nullableNumber);
const currentValues = z.object({
time: z.string(),
european_aqi: nullableNumber,
us_aqi: nullableNumber,
uv_index: nullableNumber,
pm10: nullableNumber,
pm2_5: nullableNumber,
carbon_monoxide: nullableNumber,
nitrogen_dioxide: nullableNumber,
sulphur_dioxide: nullableNumber,
ozone: nullableNumber,
european_aqi_pm2_5: nullableNumber,
european_aqi_pm10: nullableNumber,
european_aqi_nitrogen_dioxide: nullableNumber,
european_aqi_ozone: nullableNumber,
european_aqi_sulphur_dioxide: nullableNumber,
us_aqi_pm2_5: nullableNumber,
us_aqi_pm10: nullableNumber,
us_aqi_nitrogen_dioxide: nullableNumber,
us_aqi_carbon_monoxide: nullableNumber,
us_aqi_ozone: nullableNumber,
us_aqi_sulphur_dioxide: nullableNumber,
alder_pollen: nullableNumber,
birch_pollen: nullableNumber,
grass_pollen: nullableNumber,
mugwort_pollen: nullableNumber,
olive_pollen: nullableNumber,
ragweed_pollen: nullableNumber,
});
const hourlyValues = z.object({
time: z.array(z.string()),
european_aqi: nullableNumberArray,
us_aqi: nullableNumberArray,
uv_index: nullableNumberArray,
pm10: nullableNumberArray,
pm2_5: nullableNumberArray,
carbon_monoxide: nullableNumberArray,
nitrogen_dioxide: nullableNumberArray,
sulphur_dioxide: nullableNumberArray,
ozone: nullableNumberArray,
european_aqi_pm2_5: nullableNumberArray,
european_aqi_pm10: nullableNumberArray,
european_aqi_nitrogen_dioxide: nullableNumberArray,
european_aqi_ozone: nullableNumberArray,
european_aqi_sulphur_dioxide: nullableNumberArray,
us_aqi_pm2_5: nullableNumberArray,
us_aqi_pm10: nullableNumberArray,
us_aqi_nitrogen_dioxide: nullableNumberArray,
us_aqi_carbon_monoxide: nullableNumberArray,
us_aqi_ozone: nullableNumberArray,
us_aqi_sulphur_dioxide: nullableNumberArray,
alder_pollen: nullableNumberArray,
birch_pollen: nullableNumberArray,
grass_pollen: nullableNumberArray,
mugwort_pollen: nullableNumberArray,
olive_pollen: nullableNumberArray,
ragweed_pollen: nullableNumberArray,
});
const atLocationOutput = z.object({
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: currentValues,
hourly: hourlyValues,
});
type CurrentValues = z.infer<typeof currentValues>;
type HourlyValues = z.infer<typeof hourlyValues>;
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
const value = values[field];
if (Array.isArray(value)) return value[index ?? 0] ?? null;
return value;
};
const normalizePoint = (
values: CurrentValues | HourlyValues,
index: number | undefined,
observedAt: string,
): AirQualityPoint => ({
observedAt,
europeanAqi: getValue(values, "european_aqi", index),
usAqi: getValue(values, "us_aqi", index),
uvIndex: getValue(values, "uv_index", index),
pollutants: {
pm2_5: getValue(values, "pm2_5", index),
pm10: getValue(values, "pm10", index),
ozone: getValue(values, "ozone", index),
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
carbonMonoxide: getValue(values, "carbon_monoxide", index),
},
europeanSubIndices: {
pm2_5: getValue(values, "european_aqi_pm2_5", index),
pm10: getValue(values, "european_aqi_pm10", index),
ozone: getValue(values, "european_aqi_ozone", index),
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: getValue(values, "us_aqi_pm2_5", index),
pm10: getValue(values, "us_aqi_pm10", index),
ozone: getValue(values, "us_aqi_ozone", index),
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
},
pollen: {
alder: getValue(values, "alder_pollen", index),
birch: getValue(values, "birch_pollen", index),
grass: getValue(values, "grass_pollen", index),
mugwort: getValue(values, "mugwort_pollen", index),
olive: getValue(values, "olive_pollen", index),
ragweed: getValue(values, "ragweed_pollen", index),
},
});
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
const grouped = new Map<string, AirQuality["daily"][number]>();
hourly.time.forEach((time, index) => {
const date = time.slice(0, 10);
const existing = grouped.get(date) ?? {
date,
europeanAqiMax: null,
usAqiMax: null,
uvIndexMax: null,
pm2_5Max: null,
pm10Max: null,
};
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
grouped.set(date, existing);
});
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
};
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
if (candidate === null || candidate === undefined) return current;
if (current === null) return candidate;
return Math.max(current, candidate);
};
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
return parsed.utc().toISOString();
};
export interface AirQualityPollutants {
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogenDioxide: number | null;
sulphurDioxide: number | null;
carbonMonoxide: number | null;
}
export interface AirQualityPoint {
observedAt: string;
europeanAqi: number | null;
usAqi: number | null;
uvIndex: number | null;
pollutants: AirQualityPollutants;
europeanSubIndices: AirQualityPollutants;
usSubIndices: AirQualityPollutants;
pollen: {
alder: number | null;
birch: number | null;
grass: number | null;
mugwort: number | null;
olive: number | null;
ragweed: number | null;
};
}
export interface AirQuality {
timezone: string;
utcOffsetSeconds: number;
current: AirQualityPoint;
hourly: AirQualityPoint[];
daily: {
date: string;
europeanAqiMax: number | null;
usAqiMax: number | null;
uvIndexMax: number | null;
pm2_5Max: number | null;
pm10Max: number | null;
}[];
}
@@ -2,11 +2,14 @@ import { createRequestHandler } from "./request-handler";
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
const inner = createRequestHandler<TData, TInput>(options);
return inner;
};
@@ -0,0 +1,110 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { weatherRequestHandler } from "./weather";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeWeatherResponse = () => {
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
const days = [
"2026-06-30",
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
];
const hourlyValues = hours.map<number | null>((_, index) => index);
const dailyValues = days.map<number | null>((_, index) => index);
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
temperature_2m: 24,
relative_humidity_2m: 55,
apparent_temperature: 25,
is_day: 1,
precipitation: 0,
weather_code: 1,
wind_speed_10m: 12,
wind_gusts_10m: 20,
},
hourly: {
time: hours,
temperature_2m: hourlyValues,
apparent_temperature: hourlyValues,
precipitation_probability: hourlyValues,
precipitation: hourlyValues,
weather_code: hourlyValues,
relative_humidity_2m: hourlyValues,
wind_speed_10m: hourlyValues,
wind_gusts_10m: hourlyValues,
},
daily: {
time: days,
weather_code: dailyValues,
temperature_2m_max: dailyValues,
temperature_2m_min: dailyValues,
apparent_temperature_max: dailyValues,
apparent_temperature_min: dailyValues,
precipitation_probability_max: dailyValues,
precipitation_sum: dailyValues,
relative_humidity_2m_mean: dailyValues,
wind_speed_10m_max: dailyValues,
wind_gusts_10m_max: dailyValues,
uv_index_max: dailyValues,
sunrise,
sunset: days.map((day) => `${day}T21:00`),
daylight_duration: dailyValues,
},
};
};
beforeEach(() => {
vi.clearAllMocks();
weatherRequestHandler.invalidateCache();
});
describe("weather request handler", () => {
it("requests bounded useful forecasts and normalizes location times", async () => {
const response = makeWeatherResponse();
response.hourly.temperature_2m = [null];
response.daily.sunrise[1] = null;
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.hourly).toHaveLength(24);
expect(first.data.daily).toHaveLength(7);
expect(first.data.daily[0]?.date).toBe("2026-07-01");
expect(first.data.hourly[0]?.temperature).toBeNull();
expect(first.data.hourly[1]?.temperature).toBeNull();
expect(first.data.daily[0]?.sunriseAt).toBeNull();
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.has("past_days")).toBe(false);
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
});
it("rejects unsuccessful Open-Meteo responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
});
});
+224 -43
View File
@@ -1,73 +1,254 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const HOURLY_FORECAST_LENGTH = 24;
const DAILY_FORECAST_LENGTH = 7;
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", input.latitude.toString());
url.searchParams.set("longitude", input.longitude.toString());
url.searchParams.set(
"current",
[
"temperature_2m",
"relative_humidity_2m",
"apparent_temperature",
"is_day",
"precipitation",
"weather_code",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"hourly",
[
"temperature_2m",
"apparent_temperature",
"precipitation_probability",
"precipitation",
"weather_code",
"relative_humidity_2m",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"daily",
[
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_max",
"apparent_temperature_min",
"precipitation_probability_max",
"precipitation_sum",
"relative_humidity_2m_mean",
"wind_speed_10m_max",
"wind_gusts_10m_max",
"uv_index_max",
"sunrise",
"sunset",
"daylight_duration",
].join(","),
);
url.searchParams.set("timezone", "auto");
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
if (!res.ok) throw new ResponseError(res);
const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json);
const currentLocalDate = weather.current.time.slice(0, 10);
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
const dailyIndexes = weather.daily.time
.map((_, index) => index)
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
return {
timezone: weather.timezone,
utcOffsetSeconds: weather.utc_offset_seconds,
current: {
observedAt: toUtcInstant(weather.current.time, weather.timezone),
temperature: weather.current.temperature_2m,
apparentTemperature: weather.current.apparent_temperature,
relativeHumidity: weather.current.relative_humidity_2m,
precipitation: weather.current.precipitation,
weatherCode: weather.current.weather_code,
weathercode: weather.current.weather_code,
windSpeed: weather.current.wind_speed_10m,
windspeed: weather.current.wind_speed_10m,
windGusts: weather.current.wind_gusts_10m,
isDay: weather.current.is_day === 1,
},
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
return {
observedAt: toUtcInstant(value, weather.timezone),
temperature: weather.hourly.temperature_2m[index] ?? null,
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
precipitation: weather.hourly.precipitation[index] ?? null,
weatherCode: weather.hourly.weather_code[index] ?? null,
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
};
}),
daily: dailyIndexes.map((index) => {
const value = weather.daily.time[index] ?? currentLocalDate;
return {
date: value,
time: value,
weatherCode: weather.daily.weather_code[index] ?? 404,
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
precipitation: weather.daily.precipitation_sum[index] ?? null,
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
uvIndex: weather.daily.uv_index_max[index] ?? null,
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
sunrise: weather.daily.sunrise[index] ?? undefined,
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
sunset: weather.daily.sunset[index] ?? undefined,
daylightDuration: weather.daily.daylight_duration[index] ?? null,
};
}),
} satisfies Weather;
};
export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
const json: unknown = await res.json();
const weather = await 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],
sunrise: weather.daily.sunrise[index],
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
},
cacheTtlMs: 5 * 60 * 1000,
requestAsync: requestWeatherAsync,
});
const nullableNumberArray = z.array(z.number().nullable());
const atLocationOutput = z.object({
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
windspeed: z.number(),
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: z.object({
time: z.string(),
temperature_2m: z.number(),
relative_humidity_2m: z.number(),
apparent_temperature: z.number(),
is_day: z.number(),
precipitation: z.number(),
weather_code: z.number(),
wind_speed_10m: z.number(),
wind_gusts_10m: z.number(),
}),
hourly: z.object({
time: z.array(z.string()),
temperature_2m: nullableNumberArray,
apparent_temperature: nullableNumberArray,
precipitation_probability: nullableNumberArray,
precipitation: nullableNumberArray,
weather_code: nullableNumberArray,
relative_humidity_2m: nullableNumberArray,
wind_speed_10m: nullableNumberArray,
wind_gusts_10m: nullableNumberArray,
}),
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()),
sunrise: z.array(z.string()),
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
weather_code: nullableNumberArray,
temperature_2m_max: nullableNumberArray,
temperature_2m_min: nullableNumberArray,
apparent_temperature_max: nullableNumberArray,
apparent_temperature_min: nullableNumberArray,
precipitation_probability_max: nullableNumberArray,
precipitation_sum: nullableNumberArray,
relative_humidity_2m_mean: nullableNumberArray,
wind_speed_10m_max: nullableNumberArray,
wind_gusts_10m_max: nullableNumberArray,
uv_index_max: nullableNumberArray,
sunrise: z.array(z.string().nullable()),
sunset: z.array(z.string().nullable()),
daylight_duration: nullableNumberArray,
}),
});
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
return parsed.utc().toISOString();
};
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
if (!value) return null;
return toUtcInstant(value, timeZone);
};
export interface Weather {
timezone: string;
utcOffsetSeconds: number;
current: {
weathercode: number;
temperature: number;
apparentTemperature: number;
relativeHumidity: number;
precipitation: number;
weatherCode: number;
weathercode: number;
windSpeed: number;
windspeed: number;
windGusts: number;
isDay: boolean;
observedAt: string;
};
hourly: {
observedAt: string;
temperature: number | null;
apparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
weatherCode: number | null;
relativeHumidity: number | null;
windSpeed: number | null;
windGusts: number | null;
}[];
daily: {
date: string;
time: string;
weatherCode: number;
maxTemperature: number | null;
maxTemp: number | undefined;
minTemperature: number | null;
minTemp: number | undefined;
sunrise: string | undefined;
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
maxApparentTemperature: number | null;
minApparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
averageHumidity: number | null;
humidity: number | undefined;
maxWindSpeed: number | null;
maxWindGusts: number | null;
uvIndex: number | null;
sunriseAt: string | null;
sunrise: string | undefined;
sunsetAt: string | null;
sunset: string | undefined;
daylightDuration: number | null;
}[];
}