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,20 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { airQualityRequestHandler } from "@homarr/request-handler/air-quality";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const airQualityRouter = createTRPCRouter({
atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const handler = airQualityRequestHandler.handler(input);
return await handler.getDataAsync().then((result) => result.data);
}),
});
+1
View File
@@ -13,6 +13,7 @@ export const widgetRouter = createTRPCRouter({
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
+2 -2
View File
@@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const atLocationInput = z.object({
longitude: z.number(),
latitude: z.number(),
longitude: z.number().min(-180).max(180),
latitude: z.number().min(-90).max(90),
});
export const weatherRouter = createTRPCRouter({
@@ -336,6 +336,10 @@ export type HomarrDocumentationPath =
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/today"
| "/docs/widgets/air-quality"
| "/docs/widgets/countdown"
| "/docs/widgets/timer"
| "/docs/widgets/app"
| "/docs/widgets/iframe"
| "/docs/widgets/video"
@@ -3,6 +3,10 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
today: "today",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
app: "app",
iframe: "iframe",
video: "video",
+8
View File
@@ -1,6 +1,10 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -59,6 +63,10 @@ export const widgetKinds = [
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
today: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
@@ -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;
}[];
}
+332 -12
View File
@@ -3153,9 +3153,6 @@
"label": "24-hour format",
"description": "Use 24-hour format instead of 12-hour format"
},
"showSeconds": {
"label": "Display seconds"
},
"useCustomTimezone": {
"label": "Use a fixed timezone"
},
@@ -3167,16 +3164,12 @@
"label": "Show the date"
},
"dateFormat": {
"label": "Date Format",
"description": "How the date should look like"
"label": "Date format",
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
},
"customTimeFormat": {
"label": "Custom time format",
"description": "Use ISO 8601 to format time (this will override other options)"
},
"customDateFormat": {
"label": "Custom date format",
"description": "Use ISO 8601 to format date (this will override other options)"
"label": "Time format",
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
},
"showWeather": {
"label": "Show weather",
@@ -3187,7 +3180,298 @@
},
"isWeatherFormatFahrenheit": {
"label": "Use Fahrenheit"
},
"animateWeatherIcon": {
"label": "Animate weather icon",
"description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested."
},
"worldClockCities": {
"label": "World clock cities",
"description": "Add and order up to six cities for advanced view."
}
},
"timezoneList": {
"timezone": "Timezone",
"cityLabel": "City label",
"searchPlaceholder": "Search timezones",
"noResults": "No timezones found",
"unsupportedOption": "Unsupported: {timeZone}",
"add": "Add city",
"remove": "Remove {city}",
"reorder": "Reorder {city}",
"limit": "{count} of {maximum} cities configured"
},
"worldClock": {
"primary": "Primary clock",
"local": "Local time",
"today": "Today",
"tomorrow": "Tomorrow",
"yesterday": "Yesterday",
"noSecondaryCities": "Add cities in widget options to compare them here.",
"unsupportedPrimaryTitle": "Primary timezone is unavailable",
"unsupportedPrimary": "{timeZone} is not supported in this browser. Showing UTC as a clearly marked fallback.",
"unsupportedTimeZone": "Unsupported timezone: {timeZone}",
"accessibleTime": "{city}: {time}, {date}, {timeZone}",
"advancedLabel": "Advanced world clock",
"cities": "World clocks",
"cityCount": "{count} timezones",
"timeBarLabel": "24-hour time-of-day position for {city}",
"dayProgress": "{percent}% elapsed · {hours}h {minutes}m remaining",
"phaseLegend": "Time-of-day legend",
"phase": {
"night": "Night",
"dawn": "Dawn",
"day": "Day",
"dusk": "Dusk"
}
}
},
"airQuality": {
"name": "Air Quality & UV",
"description": "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
"option": {
"location": {
"label": "Air quality location"
},
"aqiStandard": {
"label": "AQI standard",
"options": {
"auto": "Automatic for locale",
"european": "European AQI",
"us": "US AQI"
}
},
"showUv": {
"label": "Show UV index"
},
"showPollutants": {
"label": "Show pollutant concentrations"
},
"showPollen": {
"label": "Show pollen concentrations"
}
},
"category": {
"good": "Good",
"fair": "Fair",
"moderate": "Moderate",
"unhealthySensitive": "Unhealthy for sensitive groups",
"unhealthy": "Unhealthy",
"poor": "Poor",
"veryPoor": "Very poor",
"veryUnhealthy": "Very unhealthy",
"extremelyPoor": "Extremely poor",
"hazardous": "Hazardous",
"unknown": "No AQI reading"
},
"uv": {
"label": "UV index",
"category": {
"low": "Low",
"moderate": "Moderate",
"high": "High",
"veryHigh": "Very high",
"extreme": "Extreme",
"unknown": "No reading"
}
},
"dominantPollutant": "Dominant pollutant",
"strongestPollen": "Highest pollen",
"pollutant": {
"pm2_5": "PM2.5",
"pm10": "PM10",
"ozone": "Ozone",
"nitrogenDioxide": "Nitrogen dioxide",
"sulphurDioxide": "Sulphur dioxide",
"carbonMonoxide": "Carbon monoxide"
},
"pollen": {
"alder": "Alder",
"birch": "Birch",
"grass": "Grass",
"mugwort": "Mugwort",
"olive": "Olive",
"ragweed": "Ragweed"
},
"compact": {
"sixHourTrend": "AQI trend for the next six hours"
},
"advanced": {
"observedAt": "Updated at {time}",
"next24Hours": "Next 24 hours",
"pollutants": "Pollutant concentrations",
"pollen": "Pollen concentrations",
"sevenDayForecast": "7-day maximum forecast",
"aqiValue": "AQI {value}",
"uvValue": "UV {value}",
"uvShort": "UV {value}",
"noForecast": "No forecast data available"
}
},
"countdown": {
"name": "Countdowns",
"description": "Tracks upcoming events and recurring anniversaries.",
"option": {
"events": {
"label": "Events",
"description": "Add and order up to 20 one-time or yearly countdowns. Dates are entered in the selected timezone."
},
"showProgress": {
"label": "Show progress"
},
"showSeconds": {
"label": "Show seconds",
"description": "Keep seconds visible for every upcoming event."
}
},
"dateTimeEventList": {
"newEvent": "New event",
"add": "Add event",
"remove": "Remove {event}",
"reorder": "Reorder {event}",
"eventLabel": "Event label",
"timezone": "Timezone",
"unsupportedOption": "Unsupported: {timeZone}",
"recurrence": "Repeats",
"recurrenceOption": {
"none": "Does not repeat",
"yearly": "Every year"
},
"target": "Event date and time",
"trackProgress": "Track progress from a start date",
"start": "Progress start date and time",
"confirmDateTime": "Confirm date and time",
"limit": "{count} of {maximum} events configured"
},
"empty": {
"title": "No countdowns yet",
"description": "Add an event in widget options to start counting down."
},
"completed": "Completed",
"yearly": "Yearly",
"progress": "Event progress",
"upcoming": "Upcoming",
"completedEvents": "Completed events",
"invalid": {
"title": "Some events cannot be displayed",
"description": "{count, plural, one {One event has an invalid date or unsupported timezone.} other {# events have an invalid date or unsupported timezone.}}",
"value": "Unavailable"
},
"alert": {
"notifications": "Browser notifications",
"permissionDenied": "Notifications are blocked in this browser.",
"reached": "The countdown has reached its target."
}
},
"timer": {
"name": "Timer / Pomodoro",
"description": "Runs a simple timer or a configurable Pomodoro sequence.",
"option": {
"mode": {
"label": "Mode",
"options": {
"timer": "Timer",
"pomodoro": "Pomodoro"
}
},
"timerMinutes": {
"label": "Timer minutes"
},
"focusMinutes": {
"label": "Focus minutes"
},
"shortBreakMinutes": {
"label": "Short break minutes"
},
"longBreakMinutes": {
"label": "Long break minutes"
},
"sessionsBeforeLongBreak": {
"label": "Focus sessions before a long break"
},
"autoStartBreaks": {
"label": "Automatically start breaks"
},
"autoStartFocus": {
"label": "Automatically start focus sessions"
}
},
"phase": {
"timer": "Timer",
"focus": "Focus",
"shortBreak": "Short break",
"longBreak": "Long break"
},
"action": {
"start": "Start",
"pause": "Pause",
"reset": "Reset"
},
"attention": {
"timerComplete": "Timer complete",
"phaseReady": "{phase} ready"
},
"progress": "Timer progress: {progress}%",
"compact": {
"completedFocus": "{count, plural, =0 {No focus sessions completed} one {# focus session completed} other {# focus sessions completed}}"
},
"alert": {
"title": "Timer complete",
"completed": "{phase} completed."
},
"advanced": {
"sequence": "Pomodoro sequence",
"focusSession": "Focus {session}",
"settings": "Permanent settings",
"settingsHint": "Use the widget context menu to edit these board settings.",
"localAlerts": "Local alerts",
"notifications": "Browser notifications",
"sound": "Timer sounds",
"localAlertsHint": "Timer sounds are enabled by default. Sounds and notifications apply only to this widget in this browser.",
"notificationsDenied": "Notifications are blocked in this browser."
}
},
"today": {
"name": "Today",
"description": "Displays the current date and progress through the calendar.",
"option": {
"weekConvention": {
"label": "Week numbering",
"options": {
"locale": "Use locale rules",
"iso": "ISO 8601"
}
},
"showWeekNumber": {
"label": "Show week number"
},
"showDayOfYear": {
"label": "Show day of year"
},
"showQuarter": {
"label": "Show quarter"
},
"showYearProgress": {
"label": "Show year progress"
}
},
"weekNumber": "Week {week}",
"dayOfYear": "Day {day} of {total}",
"quarter": "Quarter {quarter}",
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
"weekProgressLabel": "Week progress",
"monthProgressLabel": "Month progress",
"yearProgressLabel": "Year progress",
"yearProgress": "Year progress: {progress}%",
"weekConvention": {
"locale": "Week numbers follow your locale",
"iso": "ISO 8601 week numbers"
},
"advanced": {
"week": "Week number",
"dayOfYear": "Day of year",
"quarter": "Quarter",
"daysRemaining": "Days after today"
}
},
"minecraftServerStatus": {
@@ -3583,6 +3867,13 @@
"disableTemperatureDecimals": {
"label": "Disable temperature decimals"
},
"animateIcons": {
"label": "Animate weather icons"
},
"showHumidity": {
"label": "Show humidity",
"description": "Display humidity in current conditions, hourly details and daily forecasts"
},
"showCurrentWindSpeed": {
"label": "Show current wind speed",
"description": "Only on current weather"
@@ -3636,11 +3927,40 @@
"detailsFor": "Show weather details for {date}",
"advanced": {
"forecast": "7-day forecast",
"hourlyForecast": "Next 24 hours",
"day": "Day",
"time": "Time",
"conditions": "Conditions",
"temperature": "High / low",
"temperature": "Temperature",
"feelsLike": "Feels like {temperature}",
"feelsLikeLabel": "Feels like",
"feelsLikeRange": "Feels like: {maximum} / {minimum}",
"highLow": "High {maximum} · low {minimum}",
"observedAt": "Updated at {time}",
"humidity": "Humidity",
"averageHumidity": "Average humidity: {humidity}%",
"currentHumidity": "Current humidity",
"wind": "Wind / gusts ({unit})",
"currentWind": "Current wind / gusts",
"windValue": "{speed} / {gusts} {unit}",
"windDetail": "Max wind / gusts: {speed} / {gusts} {unit}",
"nextHourPrecipitation": "Precipitation next hour",
"precipitationChance": "Precipitation chance",
"precipitationValue": "{probability}% · {amount} mm",
"precipitationDetail": "Precipitation: {probability}% · {amount} mm",
"todayUv": "Today's max UV index",
"uvIndex": "Max UV index: {value}",
"daylight": "Daylight: {duration}",
"hourlyTooltip": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {humidity}% humidity · {speed} {speedUnit} wind",
"hourlyTooltipWithoutHumidity": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {speed} {speedUnit} wind",
"detailLabel": {
"feelsLike": "Feels like",
"precipitation": "Precipitation",
"humidity": "Average humidity",
"wind": "Max wind / gusts",
"uv": "Max UV index",
"daylight": "Daylight"
},
"sun": "Sunrise / sunset",
"noForecast": "No forecast data available"
}
+1
View File
@@ -65,6 +65,7 @@
"@mantine/dates": "catalog:",
"@mantine/form": "catalog:",
"@mantine/hooks": "catalog:",
"@rexa-developer/tiks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tiptap/extension-color": "catalog:",
"@tiptap/extension-highlight": "catalog:",
+4
View File
@@ -3,6 +3,7 @@ import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input";
import { WidgetDateTimeEventListInput } from "./widget-date-time-event-list-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
import { WidgetLocationInput } from "./widget-location-input";
@@ -15,6 +16,7 @@ import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
import { WidgetTimezoneListInput } from "./widget-timezone-list-input";
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
@@ -26,6 +28,8 @@ const mapping = {
internal: WidgetInternalInput,
anchorNote: WidgetAnchorNoteInput,
text: WidgetTextInput,
dateTimeEventList: WidgetDateTimeEventListInput,
timezoneList: WidgetTimezoneListInput,
location: WidgetLocationInput,
multiSelect: WidgetMultiSelectInput,
multiText: WidgetMultiTextInput,
@@ -0,0 +1,317 @@
"use client";
import { useEffect, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence";
import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetDateTimeEventListInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"dateTimeEventList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useEventListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionDateTimeEvent[]) : options.defaultValue;
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const setValues = (nextValues: OptionDateTimeEvent[]) => form.setFieldValue(fieldPath, nextValues);
const addEvent = () => {
if (values.length >= options.maxValues) return;
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const target = dayjs().add(1, "day").minute(0).second(0).millisecond(0);
setValues([
...values,
{
id: createId(),
label: tEditor("newEvent"),
targetUtc: target.toISOString(),
timeZone: localTimeZone,
recurrence: "none",
},
]);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Button
type="button"
variant="light"
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addEvent}
disabled={values.length >= options.maxValues}
>
{tEditor("add")}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((event) => (
<SortableEvent
key={event.id}
kind={kind}
event={event}
timeZoneOptions={options.timeZoneOptions}
onChange={(nextEvent) =>
setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableEventProps {
kind: WidgetKind;
event: OptionDateTimeEvent;
timeZoneOptions: readonly { value: string; label: string }[];
onChange: (event: OptionDateTimeEvent) => void;
onRemove: () => void;
}
const SortableEvent = ({ kind, event, timeZoneOptions, onChange, onRemove }: SortableEventProps) => {
const t = useEventListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: event.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === event.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: event.timeZone, label: t("unsupportedOption", { timeZone: event.timeZone }) }, ...timeZoneOptions];
const targetValue = formatWallDateTime(event.targetUtc, event.timeZone);
const startValue = event.startUtc ? formatWallDateTime(event.startUtc, event.timeZone) : null;
const changeTimeZone = (timeZone: string) => {
const target = resolveWallDateTimeString(targetValue, timeZone);
const start = startValue ? resolveWallDateTimeString(startValue, timeZone) : null;
onChange({
...event,
timeZone,
targetUtc: target?.toISOString() ?? event.targetUtc,
startUtc: event.startUtc ? (start?.toISOString() ?? event.startUtc) : undefined,
});
};
const updateWallDateTime = (value: string | null, field: "targetUtc" | "startUtc") => {
if (!value) return;
const resolved = resolveWallDateTimeString(value, event.timeZone);
if (!resolved) return;
onChange({ ...event, [field]: resolved.toISOString() });
};
return (
<Card ref={setNodeRef} withBorder p="sm" style={style}>
<Stack gap="xs">
<Group gap="xs" wrap="nowrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { event: event.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("eventLabel")}
value={event.label}
maxLength={64}
onChange={(changeEvent) => onChange({ ...event, label: changeEvent.currentTarget.value })}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { event: event.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
<Group align="end" grow>
<Select
label={t("timezone")}
searchable
data={selectableTimeZones}
value={event.timeZone}
onChange={(timeZone) => {
if (timeZone) changeTimeZone(timeZone);
}}
/>
<Select
label={t("recurrence")}
value={event.recurrence}
data={(["none", "yearly"] as const).map((value) => ({ value, label: t(`recurrenceOption.${value}`) }))}
onChange={(value) => {
if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence });
}}
/>
</Group>
<ConfirmedDateTimePicker
label={t("target")}
value={targetValue || null}
onChange={(value) => updateWallDateTime(value, "targetUtc")}
confirmLabel={t("confirmDateTime")}
/>
{event.recurrence === "none" && (
<>
<Switch
label={t("trackProgress")}
checked={event.startUtc !== undefined}
onChange={(changeEvent) => {
if (!changeEvent.currentTarget.checked) {
const nextEvent = { ...event };
delete nextEvent.startUtc;
onChange(nextEvent);
return;
}
onChange({ ...event, startUtc: new Date().toISOString() });
}}
/>
{event.startUtc && (
<ConfirmedDateTimePicker
label={t("start")}
value={startValue}
onChange={(value) => updateWallDateTime(value, "startUtc")}
confirmLabel={t("confirmDateTime")}
/>
)}
</>
)}
</Stack>
</Card>
);
};
interface ConfirmedDateTimePickerProps {
label: string;
value: string | null;
confirmLabel: string;
onChange: (value: string | null) => void;
}
const ConfirmedDateTimePicker = ({ label, value, confirmLabel, onChange }: ConfirmedDateTimePickerProps) => {
const [draftValue, setDraftValue] = useState(value);
useEffect(() => setDraftValue(value), [value]);
const resetDraft = () => setDraftValue(value);
const confirmDraft = () => {
if (!draftValue) return;
onChange(draftValue);
};
return (
<DateTimePicker
label={label}
value={draftValue}
valueFormat="YYYY-MM-DD HH:mm"
timePickerProps={{ withDropdown: true, popoverProps: { withinPortal: false } }}
popoverProps={{ withinPortal: true }}
submitButtonProps={{ "aria-label": confirmLabel, onClick: confirmDraft }}
onChange={setDraftValue}
onDropdownClose={resetDraft}
/>
);
};
type EventListTranslationKey =
| "newEvent"
| "add"
| "remove"
| "reorder"
| "eventLabel"
| "timezone"
| "unsupportedOption"
| "recurrence"
| "recurrenceOption.none"
| "recurrenceOption.yearly"
| "target"
| "trackProgress"
| "start"
| "confirmDateTime"
| "limit";
type EventListTranslation = (key: EventListTranslationKey, values?: Record<string, string | number>) => string;
const useEventListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
@@ -72,6 +72,7 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput
);
}}
description={options.withDescription ? tWidget("description") : undefined}
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
searchable={options.searchable}
{...inputProps}
/>
@@ -0,0 +1,249 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core";
import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react";
import { createId } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { OptionTimezone } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTimezoneListInput = ({ property, kind, options }: CommonWidgetInputProps<"timezoneList">) => {
const t = useWidgetInputTranslation(kind, property);
const tEditor = useTimezoneListTranslation(kind);
const form = useFormContext();
const fieldPath = `options.${property}`;
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as OptionTimezone[]) : options.defaultValue;
const [selectedTimeZone, setSelectedTimeZone] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
const usedTimeZones = useMemo(() => new Set(values.map(({ timeZone }) => timeZone)), [values]);
const availableOptions = options.timeZoneOptions.map((option) => ({
...option,
disabled: usedTimeZones.has(option.value),
}));
const isFull = values.length >= options.maxValues;
const setValues = (nextValues: OptionTimezone[]) => form.setFieldValue(fieldPath, nextValues);
const addCity = (city: OptionTimezone) => {
if (isFull || usedTimeZones.has(city.timeZone)) return;
setValues([...values, city]);
};
const addSelectedTimeZone = () => {
if (!selectedTimeZone) return;
const option = options.timeZoneOptions.find(({ value }) => value === selectedTimeZone);
if (!option) return;
const label = option.label.replace(` (${option.value})`, "");
addCity({ id: createId(), label, timeZone: option.value });
setSelectedTimeZone(null);
};
return (
<Fieldset legend={t("label")}>
<Stack gap="sm">
{options.withDescription && (
<Text size="sm" c="dimmed">
{t("description")}
</Text>
)}
<Group gap="xs" align="end" wrap="nowrap">
<Select
flex={1}
label={tEditor("timezone")}
placeholder={tEditor("searchPlaceholder")}
searchable
clearable
data={availableOptions}
value={selectedTimeZone}
onChange={setSelectedTimeZone}
disabled={isFull}
nothingFoundMessage={tEditor("noResults")}
/>
<Button
leftSection={<IconPlus size="var(--mantine-font-size-md)" />}
onClick={addSelectedTimeZone}
disabled={isFull || selectedTimeZone === null}
>
{tEditor("add")}
</Button>
</Group>
<Group gap="xs">
{options.presets.map((preset) => (
<Button
key={preset.timeZone}
size="compact-xs"
variant="light"
disabled={isFull || usedTimeZones.has(preset.timeZone)}
onClick={() => addCity({ ...preset, id: createId() })}
>
{preset.label}
</Button>
))}
</Group>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const activeIndex = values.findIndex(({ id }) => id === active.id);
const overIndex = values.findIndex(({ id }) => id === over.id);
if (activeIndex < 0 || overIndex < 0) return;
setValues(arrayMove(values, activeIndex, overIndex));
}}
>
<SortableContext items={values.map(({ id }) => id)} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
{values.map((city) => (
<SortableTimezone
key={city.id}
kind={kind}
city={city}
timeZoneOptions={options.timeZoneOptions}
usedTimeZones={usedTimeZones}
onChange={(nextCity) =>
setValues(values.map((candidate) => (candidate.id === city.id ? nextCity : candidate)))
}
onRemove={() => setValues(values.filter((candidate) => candidate.id !== city.id))}
/>
))}
</Stack>
</SortableContext>
</DndContext>
<Text size="xs" c="dimmed">
{tEditor("limit", { count: values.length, maximum: options.maxValues })}
</Text>
</Stack>
</Fieldset>
);
};
interface SortableTimezoneProps {
kind: WidgetKind;
city: OptionTimezone;
timeZoneOptions: readonly { value: string; label: string }[];
usedTimeZones: ReadonlySet<string>;
onChange: (city: OptionTimezone) => void;
onRemove: () => void;
}
const SortableTimezone = ({
kind,
city,
timeZoneOptions,
usedTimeZones,
onChange,
onRemove,
}: SortableTimezoneProps) => {
const t = useTimezoneListTranslation(kind);
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: city.id,
});
const style = {
transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined,
transition,
opacity: isDragging ? 0.6 : undefined,
zIndex: isDragging ? 1 : undefined,
};
const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === city.timeZone);
const selectableTimeZones = hasCurrentTimeZone
? timeZoneOptions
: [{ value: city.timeZone, label: t("unsupportedOption", { timeZone: city.timeZone }) }, ...timeZoneOptions];
return (
<Card ref={setNodeRef} withBorder p="xs" style={style}>
<Group gap="xs" wrap="wrap" align="end">
<ActionIcon
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
variant="subtle"
color="gray"
size="lg"
aria-label={t("reorder", { city: city.label })}
style={{ cursor: "grab" }}
>
<IconGripVertical size="var(--mantine-font-size-lg)" />
</ActionIcon>
<TextInput
flex={1}
label={t("cityLabel")}
value={city.label}
onChange={(event) => onChange({ ...city, label: event.currentTarget.value })}
/>
<Select
flex={2}
label={t("timezone")}
searchable
data={selectableTimeZones.map((option) => ({
...option,
disabled: option.value !== city.timeZone && usedTimeZones.has(option.value),
}))}
value={city.timeZone}
onChange={(timeZone) => {
if (timeZone) onChange({ ...city, timeZone });
}}
/>
<ActionIcon
color="red"
variant="subtle"
size="lg"
aria-label={t("remove", { city: city.label })}
onClick={onRemove}
>
<IconTrash size="var(--mantine-font-size-lg)" />
</ActionIcon>
</Group>
</Card>
);
};
type TimezoneListTranslationKey =
| "timezone"
| "cityLabel"
| "searchPlaceholder"
| "noResults"
| "unsupportedOption"
| "add"
| "remove"
| "reorder"
| "limit";
type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record<string, string | number>) => string;
const useTimezoneListTranslation = (kind: WidgetKind) =>
useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
@@ -0,0 +1,394 @@
"use client";
import { useId } from "react";
import { CompositeChart, Sparkline } from "@mantine/charts";
import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden } from "@mantine/core";
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import type { AirQualityData, PollutantKey, PollenKey } from "./display";
import {
getAqiCategory,
getAqiColor,
getAqiStandard,
getAqiValue,
getCompactAirQualityLayout,
getDominantPollutant,
getStrongestPollen,
getUpcomingHours,
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
const t = useScopedI18n("widget.airQuality");
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
});
const airQuality = getUsableWidgetQueryData(query) as AirQualityData | null | undefined;
if (isInitialWidgetQueryPending(query)) return <WidgetQueryLoadingState />;
if (!airQuality) return <WidgetEmptyState />;
return (
<Box h="100%" w="100%" pos="relative">
{displayMode === "advanced" ? (
<AdvancedAirQuality airQuality={airQuality} options={options} width={width} />
) : (
<CompactAirQuality airQuality={airQuality} height={height} options={options} width={width} />
)}
{query.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={query.error} label={t("name")} />
</Box>
)}
</Box>
);
}
interface AirQualityViewProps {
airQuality: AirQualityData;
options: WidgetComponentProps<"airQuality">["options"];
}
const CompactAirQuality = ({
airQuality,
height,
options,
width,
}: AirQualityViewProps & { height: number; width: number }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const layout = getCompactAirQualityLayout(width, height);
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const dominant = getDominantPollutant(airQuality.current, standard);
const strongestPollen = getStrongestPollen(airQuality.current);
const labels = useAirQualityLabels();
const sixHours = getUpcomingHours(airQuality, 6)
.map((hour) => getAqiValue(hour, standard))
.filter((hour): hour is number => hour !== null);
return (
<Stack
h="100%"
w="100%"
justify="center"
align="center"
gap={layout.tiny ? 2 : "xs"}
p="xs"
style={{ overflow: "hidden" }}
>
<Group gap="xs" wrap="nowrap" align="center">
<Text fz={layout.tiny ? 34 : 42} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Stack gap={1} align="flex-start">
<Text size={layout.tiny ? "xs" : "sm"} fw={600}>
{t(`category.${category}`)}
</Text>
</Stack>
</Group>
{!layout.tiny && (
<Group gap="md" justify="center" wrap="wrap">
{options.showUv && (
<MetricLine
label={t("uv.label")}
value={`${formatDecimal(airQuality.current.uvIndex, locale)} · ${t(`uv.category.${getUvCategory(airQuality.current.uvIndex)}`)}`}
/>
)}
{options.showPollutants && dominant && (
<MetricLine label={t("dominantPollutant")} value={labels.pollutants[dominant.key]} />
)}
</Group>
)}
{!layout.tiny && layout.showParticulatesAndPollen && (
<Group gap="md" justify="center" wrap="wrap">
{options.showPollutants && (
<>
<MetricLine
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale)}
/>
<MetricLine
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale)}
/>
</>
)}
{options.showPollen && strongestPollen && (
<MetricLine
label={t("strongestPollen")}
value={`${labels.pollen[strongestPollen.key]} · ${formatPollen(strongestPollen.value, locale)}`}
/>
)}
</Group>
)}
{!layout.tiny && layout.showSparkline && sixHours.length > 1 && (
<Box component="figure" w="100%" h={34} m={0}>
<Sparkline
aria-hidden
h="100%"
w="100%"
data={sixHours}
color={getAqiColor(category)}
curveType="monotone"
fillOpacity={0.2}
strokeWidth={2}
/>
<VisuallyHidden component="figcaption">{t("compact.sixHourTrend")}</VisuallyHidden>
</Box>
)}
</Stack>
);
};
const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => {
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.airQuality");
const standard = getAqiStandard(options.aqiStandard, locale);
const value = getAqiValue(airQuality.current, standard);
const category = getAqiCategory(value, standard);
const labels = useAirQualityLabels();
const upcoming = getUpcomingHours(airQuality, 24);
const chartData = upcoming.map((hour) => ({
observedAt: hour.observedAt,
aqi: getAqiValue(hour, standard) ?? Number.NaN,
uv: hour.uvIndex ?? Number.NaN,
}));
const series = [
{ name: "aqi", label: "AQI", color: getAqiColor(category), type: "line" as const },
...(options.showUv
? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }]
: []),
];
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fz={38} fw={700} lh={1} c={getAqiColor(category)}>
{formatAqi(value, locale)}
</Text>
<Text fw={600}>{t(`category.${category}`)}</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Group gap={5} wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm" fw={600}>
{options.location.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("advanced.observedAt", {
time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }),
})}
</Text>
</Stack>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
<MetricCard label="AQI" value={formatAqi(value, locale)} />
{options.showUv && (
<MetricCard label={t("uv.label")} value={formatDecimal(airQuality.current.uvIndex, locale)} />
)}
{options.showPollutants && (
<>
<MetricCard
label={labels.pollutants.pm2_5}
value={formatConcentration(airQuality.current.pollutants.pm2_5, locale)}
/>
<MetricCard
label={labels.pollutants.pm10}
value={formatConcentration(airQuality.current.pollutants.pm10, locale)}
/>
</>
)}
</SimpleGrid>
<SimpleGrid cols={width >= 800 ? 2 : 1} spacing="md">
<Paper withBorder p="sm">
<Text id={chartId} size="sm" fw={600} mb="xs">
{t("advanced.next24Hours")}
</Text>
{chartData.length > 0 ? (
<>
<Box aria-hidden>
<CompositeChart
h={220}
data={chartData}
dataKey="observedAt"
series={series}
curveType="monotone"
gridAxis="y"
tickLine="none"
withDots={false}
withLegend
withRightYAxis={options.showUv}
rightYAxisProps={{ width: 34 }}
xAxisProps={{
tickFormatter: (time: string) =>
formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }),
}}
/>
</Box>
<VisuallyHidden>
<ul aria-labelledby={chartId}>
{upcoming.map((hour) => (
<li key={hour.observedAt}>
{formatLocalizedTime(hour.observedAt, locale, { timeZone: airQuality.timezone })}:{" "}
{t("advanced.aqiValue", {
value: formatAqi(getAqiValue(hour, standard), locale),
})}
{options.showUv
? `, ${t("advanced.uvValue", { value: formatDecimal(hour.uvIndex, locale) })}`
: ""}
</li>
))}
</ul>
</VisuallyHidden>
</>
) : (
<Text size="sm" c="dimmed">
{t("advanced.noForecast")}
</Text>
)}
</Paper>
<Stack gap="md">
{options.showPollutants && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollutants")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollutantKeys.map((key) => (
<MetricLine
key={key}
label={labels.pollutants[key]}
value={formatConcentration(airQuality.current.pollutants[key], locale)}
/>
))}
</SimpleGrid>
</Paper>
)}
{options.showPollen && hasAvailableValue(airQuality.current.pollen) && (
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.pollen")}
</Text>
<SimpleGrid cols={2} spacing="xs">
{pollenKeys.map((key) => {
const pollen = airQuality.current.pollen[key];
if (pollen === null) return null;
return <MetricLine key={key} label={labels.pollen[key]} value={formatPollen(pollen, locale)} />;
})}
</SimpleGrid>
</Paper>
)}
</Stack>
</SimpleGrid>
<Paper withBorder p="sm">
<Text size="sm" fw={600} mb="xs">
{t("advanced.sevenDayForecast")}
</Text>
<SimpleGrid cols={{ base: 2, sm: 4, md: 7 }} spacing="xs">
{airQuality.daily.map((day) => {
const dailyAqi = standard === "us" ? day.usAqiMax : day.europeanAqiMax;
return (
<Stack key={day.date} gap={2} align="center">
<Text size="xs" c="dimmed">
{formatLocalizedDate(day.date, locale, { weekday: "short" })}
</Text>
<Text fw={700} c={getAqiColor(getAqiCategory(dailyAqi, standard))}>
{formatAqi(dailyAqi, locale)}
</Text>
{options.showUv && (
<Text size="xs">{t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })}</Text>
)}
</Stack>
);
})}
</SimpleGrid>
</Paper>
</Stack>
</ScrollArea>
);
};
const MetricCard = ({ label, value }: { label: string; value: string }) => (
<Paper withBorder p="xs">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fz="lg" fw={700}>
{value}
</Text>
</Paper>
);
const MetricLine = ({ label, value }: { label: string; value: string }) => (
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed" truncate>
{label}
</Text>
<Text size="sm" fw={600} truncate>
{value}
</Text>
</Stack>
);
const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxide", "sulphurDioxide", "carbonMonoxide"];
const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"];
const useAirQualityLabels = () => {
const t = useScopedI18n("widget.airQuality");
return {
pollutants: {
pm2_5: t("pollutant.pm2_5"),
pm10: t("pollutant.pm10"),
ozone: t("pollutant.ozone"),
nitrogenDioxide: t("pollutant.nitrogenDioxide"),
sulphurDioxide: t("pollutant.sulphurDioxide"),
carbonMonoxide: t("pollutant.carbonMonoxide"),
} satisfies Record<PollutantKey, string>,
pollen: {
alder: t("pollen.alder"),
birch: t("pollen.birch"),
grass: t("pollen.grass"),
mugwort: t("pollen.mugwort"),
olive: t("pollen.olive"),
ragweed: t("pollen.ragweed"),
} satisfies Record<PollenKey, string>,
};
};
const formatAqi = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value);
};
const formatDecimal = (value: number | null, locale: string) => {
if (value === null) return "—";
return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
};
const formatConcentration = (value: number | null, locale: string) => `${formatDecimal(value, locale)} µg/m³`;
const formatPollen = (value: number | null, locale: string) => `${formatDecimal(value, locale)} grains/m³`;
const hasAvailableValue = (values: Record<string, number | null>) =>
Object.values(values).some((value) => value !== null);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
getAqiCategory,
getAqiStandard,
getCompactAirQualityLayout,
getDominantPollutant,
getStrongestPollen,
} from "./display";
const point = {
observedAt: "2026-07-01T10:00:00.000Z",
europeanAqi: 42,
usAqi: 60,
uvIndex: 4,
pollutants: { pm2_5: 12, pm10: 20, ozone: 30, nitrogenDioxide: 4, sulphurDioxide: 3, carbonMonoxide: 100 },
europeanSubIndices: {
pm2_5: 20,
pm10: 31,
ozone: 42,
nitrogenDioxide: 10,
sulphurDioxide: 5,
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: 50,
pm10: 40,
ozone: 30,
nitrogenDioxide: 20,
sulphurDioxide: 10,
carbonMonoxide: 60,
},
pollen: { alder: null, birch: 12, grass: 20, mugwort: 5, olive: null, ragweed: 2 },
};
describe("air quality display", () => {
it("uses the US scale only for US locales in automatic mode", () => {
expect(getAqiStandard("auto", "en-US")).toBe("us");
expect(getAqiStandard("auto", "fr-FR")).toBe("european");
expect(getAqiStandard("auto", "en")).toBe("european");
expect(getAqiStandard("us", "fr-FR")).toBe("us");
});
it("uses the defined AQI band boundaries", () => {
expect(getAqiCategory(20, "european")).toBe("good");
expect(getAqiCategory(21, "european")).toBe("fair");
expect(getAqiCategory(101, "european")).toBe("extremelyPoor");
expect(getAqiCategory(50, "us")).toBe("good");
expect(getAqiCategory(51, "us")).toBe("moderate");
expect(getAqiCategory(301, "us")).toBe("hazardous");
});
it("finds dominant subindices and raw pollen without combining them", () => {
expect(getDominantPollutant(point, "european")).toEqual({ key: "ozone", value: 42 });
expect(getDominantPollutant(point, "us")).toEqual({ key: "carbonMonoxide", value: 60 });
expect(getStrongestPollen(point)).toEqual({ key: "grass", value: 20 });
});
it("progressively reveals compact details", () => {
expect(getCompactAirQualityLayout(149, 200)).toMatchObject({ tiny: true });
expect(getCompactAirQualityLayout(250, 150)).toMatchObject({ showParticulatesAndPollen: true });
expect(getCompactAirQualityLayout(300, 170)).toMatchObject({ showSparkline: true });
});
});
+139
View File
@@ -0,0 +1,139 @@
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: Record<"alder" | "birch" | "grass" | "mugwort" | "olive" | "ragweed", number | null>;
}
export interface AirQualityData {
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;
}[];
}
export type AqiStandard = "european" | "us";
export type AqiStandardOption = "auto" | AqiStandard;
export type AqiCategory =
| "good"
| "fair"
| "moderate"
| "unhealthySensitive"
| "unhealthy"
| "poor"
| "veryPoor"
| "veryUnhealthy"
| "extremelyPoor"
| "hazardous"
| "unknown";
export type UvCategory = "low" | "moderate" | "high" | "veryHigh" | "extreme" | "unknown";
export type PollutantKey = keyof AirQualityPoint["pollutants"];
export type PollenKey = keyof AirQualityPoint["pollen"];
export const getAqiStandard = (option: AqiStandardOption, locale: string): AqiStandard => {
if (option !== "auto") return option;
try {
return new Intl.Locale(locale).region === "US" ? "us" : "european";
} catch {
return "european";
}
};
export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): number | null => {
if (standard === "us") return point.usAqi;
return point.europeanAqi;
};
export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => {
if (value === null) return "unknown";
if (standard === "us") {
if (value <= 50) return "good";
if (value <= 100) return "moderate";
if (value <= 150) return "unhealthySensitive";
if (value <= 200) return "unhealthy";
if (value <= 300) return "veryUnhealthy";
return "hazardous";
}
if (value <= 20) return "good";
if (value <= 40) return "fair";
if (value <= 60) return "moderate";
if (value <= 80) return "poor";
if (value <= 100) return "veryPoor";
return "extremelyPoor";
};
export const getAqiColor = (category: AqiCategory): string => {
if (category === "good") return "green.6";
if (category === "fair") return "lime.7";
if (category === "moderate") return "yellow.7";
if (category === "unhealthySensitive" || category === "poor") return "orange.7";
if (category === "unhealthy" || category === "veryPoor") return "red.7";
if (category === "veryUnhealthy" || category === "extremelyPoor") return "grape.7";
if (category === "hazardous") return "dark.6";
return "gray.6";
};
export const getUvCategory = (value: number | null): UvCategory => {
if (value === null) return "unknown";
if (value < 3) return "low";
if (value < 6) return "moderate";
if (value < 8) return "high";
if (value < 11) return "veryHigh";
return "extreme";
};
export const getDominantPollutant = (
point: AirQualityPoint,
standard: AqiStandard,
): { key: PollutantKey; value: number } | null => {
const values = standard === "us" ? point.usSubIndices : point.europeanSubIndices;
return getHighestValue(values);
};
export const getStrongestPollen = (point: AirQualityPoint): { key: PollenKey; value: number } | null =>
getHighestValue(point.pollen);
const getHighestValue = <TKey extends string>(
values: Record<TKey, number | null>,
): { key: TKey; value: number } | null => {
let highest: { key: TKey; value: number } | null = null;
for (const [key, value] of Object.entries(values) as [TKey, number | null][]) {
if (value === null) continue;
if (highest === null || value > highest.value) highest = { key, value };
}
return highest;
};
export const getCompactAirQualityLayout = (width: number, height: number) => ({
tiny: width < 150 || height < 96,
showParticulatesAndPollen: width >= 240 && height >= 140,
showSparkline: width >= 300 && height >= 170,
});
export const getUpcomingHours = (data: AirQualityData, count: number): AirQualityData["hourly"] => {
const currentTime = Date.parse(data.current.observedAt);
return data.hourly.filter((hour) => Date.parse(hour.observedAt) >= currentTime).slice(0, count);
};
+43
View File
@@ -0,0 +1,43 @@
import { IconWind } from "@tabler/icons-react";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("airQuality", {
icon: IconWind,
supportsAdvancedFocus: true,
queryKey: [["widget", "airQuality", "atLocation"]],
refetchInterval: 900,
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
return false;
}
return widgetQueryInputMatches(input, {
latitude: location.latitude,
longitude: location.longitude,
});
},
createOptions() {
return optionsBuilder.from((factory) => ({
location: factory.location({
defaultValue: {
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
},
}),
aqiStandard: factory.select({
defaultValue: "auto",
options: [
{ value: "auto", label: (t) => t("widget.airQuality.option.aqiStandard.options.auto") },
{ value: "european", label: (t) => t("widget.airQuality.option.aqiStandard.options.european") },
{ value: "us", label: (t) => t("widget.airQuality.option.aqiStandard.options.us") },
],
}),
showUv: factory.switch({ defaultValue: true }),
showPollutants: factory.switch({ defaultValue: true }),
showPollen: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
+8
View File
@@ -15,15 +15,18 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
IconClockPlay,
IconCloud,
IconDeviceCctv,
IconDeviceGamepad,
IconDownload,
IconFileText,
IconGraphFilled,
IconHourglass,
IconHeadphones,
IconHeartRateMonitor,
IconHeartbeat,
@@ -47,6 +50,7 @@ import {
IconTransform,
IconVideo,
IconWall,
IconWind,
IconZoomQuestion,
} from "@tabler/icons-react";
@@ -56,6 +60,10 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
@@ -0,0 +1,329 @@
import {
Alert,
Badge,
Box,
Divider,
Group,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconAlertTriangle, IconClock } from "@tabler/icons-react";
import type { Dayjs } from "dayjs";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
import { clockTimeFormatShowsSeconds, clockTimeFormatUses12Hours, resolveClockTimeFormat } from "./format";
import { TimeOfDayBar } from "./time-of-day-bar";
import { ClockWeatherSummary } from "./weather-summary";
import {
createWorldClockTime,
formatRelativeOffset,
formatUtcOffset,
getTimeOfDayPhase,
humanizeTimeZone,
maximumWorldClockCities,
} from "./world-clock";
import type { TimeOfDayPhase, WorldClockTime } from "./world-clock";
interface AdvancedClockViewProps {
now: Date;
options: WidgetComponentProps<"clock">["options"];
primaryTime: Dayjs;
primaryTimeZone: string;
primaryTimeZoneInvalid: boolean;
}
export const AdvancedClockView = ({
now,
options,
primaryTime,
primaryTimeZone,
primaryTimeZoneInvalid,
}: AdvancedClockViewProps) => {
const t = useScopedI18n("widget.clock");
const primaryLabel = getPrimaryLabel(options, t);
const primary: WorldClockTime = {
id: "primary",
label: primaryLabel,
timeZone: primaryTimeZone,
zonedTime: primaryTime,
phase: getTimeOfDayPhase(primaryTime.hour()),
minuteOfDay: primaryTime.hour() * 60 + primaryTime.minute() + primaryTime.second() / 60,
utcOffset: primaryTime.utcOffset(),
dayDifference: 0,
};
const cities = options.worldClockCities
.slice(0, maximumWorldClockCities)
.filter(({ timeZone }) => timeZone !== primaryTimeZone)
.map((city) => createWorldClockTime({ city, now, primaryTime }));
return (
<ScrollArea
h="100%"
w="100%"
type="auto"
offsetScrollbars
scrollbarSize={6}
aria-label={t("worldClock.advancedLabel")}
>
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
{primaryTimeZoneInvalid && (
<Alert icon={<IconAlertTriangle />} color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}>
{t("worldClock.unsupportedPrimary", { timeZone: options.timezone })}
</Alert>
)}
<PrimaryClock now={now} options={options} primary={primary} />
<OverviewMode now={now} options={options} primary={primary} cities={cities} />
</Stack>
</ScrollArea>
);
};
interface ModeProps {
now: Date;
options: WidgetComponentProps<"clock">["options"];
primary: WorldClockTime;
cities: WorldClockTime[];
}
const PrimaryClock = ({ now, options, primary }: Pick<ModeProps, "now" | "options" | "primary">) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
const primaryTime = primary.zonedTime;
if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" gap="lg" wrap="wrap">
<Stack gap={5} miw={0}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={getPhaseColor(primary.phase)} variant="light" radius="xl" size="md">
<IconClock size={16} />
</ThemeIcon>
<Title order={2} size="h4" fw={600}>
{primary.label}
</Title>
</Group>
<ClockTime entry={primary} now={now} options={options} primary />
{options.showDate && (
<Text size="sm" c="dimmed">
{formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })}
</Text>
)}
<Group gap="xs" wrap="wrap">
<Badge color="gray" variant="light" tt="none">
{primary.timeZone}
</Badge>
<Badge color="gray" variant="outline" tt="none">
{formatUtcOffset(primaryTime.utcOffset())}
</Badge>
</Group>
</Stack>
{options.showWeather && (
<Paper withBorder radius="md" p="sm">
<ClockWeatherSummary
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon}
detailed
/>
</Paper>
)}
</Group>
<Divider my="sm" />
<TimeOfDayBar
minuteOfDay={primary.minuteOfDay}
phase={primary.phase}
showLegend
showTicks
label={t("worldClock.timeBarLabel", { city: primary.label })}
/>
</Paper>
);
};
const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
const t = useScopedI18n("widget.clock");
if (cities.length === 0) return <EmptyCities />;
return (
<Stack gap="xs">
<Group justify="space-between" align="baseline">
<Title order={3} size="h5">
{t("worldClock.cities")}
</Title>
<Text size="xs" c="dimmed">
{t("worldClock.cityCount", { count: cities.length })}
</Text>
</Group>
<SimpleGrid component="ul" cols={{ base: 1, sm: 2 }} spacing="sm" p={0} m={0} style={{ listStyle: "none" }}>
{cities.map((city) => (
<WorldClockCard
key={city.id}
entry={city}
now={now}
options={options}
primaryOffset={primary.utcOffset ?? 0}
/>
))}
</SimpleGrid>
</Stack>
);
};
interface ClockEntryProps {
entry: WorldClockTime;
now: Date;
options: WidgetComponentProps<"clock">["options"];
primaryOffset: number;
}
const WorldClockCard = ({ entry, now, options, primaryOffset }: ClockEntryProps) => {
if (!entry.zonedTime || entry.utcOffset === null) {
return <InvalidTimeZone entry={entry} />;
}
return (
<Paper component="li" withBorder radius="md" p="md">
<Stack gap="sm">
<Stack gap={0} miw={0}>
<Text fw={600} truncate>
{entry.label}
</Text>
<Text size="xs" c="dimmed" truncate>
{entry.timeZone}
</Text>
</Stack>
<ClockTime entry={entry} now={now} options={options} compact />
<DayAndOffset entry={entry} now={now} primaryOffset={primaryOffset} />
</Stack>
</Paper>
);
};
const ClockTime = ({
entry,
now,
options,
primary = false,
compact = false,
}: Omit<ClockEntryProps, "primaryOffset"> & { primary?: boolean; compact?: boolean }) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
if (!entry.zonedTime) return null;
let formattedTime = formatLocalizedTime(now, locale, {
hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat),
includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat),
timeZone: entry.timeZone,
});
if (primary) {
formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat));
}
const accessibleDate = formatLocalizedDate(now, locale, {
dateStyle: "medium",
timeZone: entry.timeZone,
});
const fontSize = compact ? { base: 30, sm: 34 } : { base: 44, sm: 56 };
return (
<Text
component="time"
dateTime={now.toISOString()}
fz={fontSize}
fw={700}
ff="var(--mantine-font-family-monospace)"
lh={1}
lts="-0.04em"
aria-label={t("worldClock.accessibleTime", {
city: entry.label,
time: formattedTime,
date: accessibleDate,
timeZone: entry.timeZone,
})}
>
{formattedTime}
</Text>
);
};
const DayAndOffset = ({ entry, now, primaryOffset }: Pick<ClockEntryProps, "entry" | "now" | "primaryOffset">) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.clock");
if (entry.dayDifference === null || entry.utcOffset === null) return null;
const dayRelation = getDayRelation(entry.dayDifference, t);
const date = formatLocalizedDate(now, locale, {
weekday: "short",
month: "short",
day: "numeric",
timeZone: entry.timeZone,
});
return (
<Group gap="xs" wrap="wrap">
<Badge color="gray" variant="light" size="sm" tt="none">
{date} · {dayRelation}
</Badge>
<Text size="xs" c="dimmed">
{formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)}
</Text>
</Group>
);
};
const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
const t = useScopedI18n("widget.clock");
return (
<Paper component="li" withBorder radius="md" p="md">
<Group gap="xs" wrap="nowrap">
<IconAlertTriangle color="var(--mantine-color-yellow-6)" aria-hidden />
<Box>
<Text fw={600}>{entry.label}</Text>
<Text size="xs" c="dimmed">
{t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })}
</Text>
</Box>
</Group>
</Paper>
);
};
const EmptyCities = () => {
const t = useScopedI18n("widget.clock");
return (
<Paper withBorder radius="md" p="xl">
<Text c="dimmed" ta="center">
{t("worldClock.noSecondaryCities")}
</Text>
</Paper>
);
};
type ScopedClockTranslator = ReturnType<typeof useScopedI18n<"widget.clock">>;
const getDayRelation = (difference: number, t: ScopedClockTranslator) => {
if (difference < 0) return t("worldClock.yesterday");
if (difference > 0) return t("worldClock.tomorrow");
return t("worldClock.today");
};
const getPrimaryLabel = (options: ModeProps["options"], t: ScopedClockTranslator) => {
if (options.customTitleToggle) return options.customTitle || t("worldClock.primary");
if (options.useCustomTimezone) return humanizeTimeZone(options.timezone);
return t("worldClock.local");
};
const getPhaseColor = (phase: TimeOfDayPhase) => {
if (phase === "night") return "indigo";
if (phase === "dawn") return "orange";
if (phase === "day") return "cyan";
return "grape";
};
+49 -152
View File
@@ -1,51 +1,62 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezones from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
import { useCurrentTime } from "./use-current-time";
import { ClockWeatherSummary } from "./weather-summary";
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const isAdvanced = displayMode === "advanced";
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
const customTimeFormat = options.customTimeFormat;
const customDateFormat = options.customDateFormat;
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds });
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
if (isAdvanced) {
if (time && zonedTime) {
return (
<AdvancedClockView
now={time}
options={options}
primaryTime={zonedTime}
primaryTimeZone={primaryTimeZone}
primaryTimeZoneInvalid={primaryTimeZoneInvalid}
/>
);
}
return <Center h="100%">--:--</Center>;
}
const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat);
const minimumAxis = Math.min(width, height * 1.5);
const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
let sizing: "xs" | "sm" | "md" = "md";
if (minimumAxis < 128) sizing = "xs";
else if (minimumAxis < 196) sizing = "sm";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
<Box className="clock-widget-container" h="100%" pos="relative">
{showWeatherCorner && (
<ClockWeatherCorner
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
showDetails={isAdvanced}
/>
<Box pos="absolute" top={4} left={4}>
<ClockWeatherSummary
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
locationName={options.weatherLocation.name}
isFahrenheit={options.isWeatherFormatFahrenheit}
animateIcon={options.animateWeatherIcon}
detailed={false}
/>
</Box>
)}
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
{options.customTitleToggle && (
@@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
{options.customTitle}
</Text>
)}
<Title className="clock-time-text" fw={700} order={sizing === "md" ? 2 : sizing === "sm" ? 4 : 6} lh="1">
<Title className="clock-time-text" fw={700} order={getTitleOrder(sizing)} lh="1">
<time dateTime={zonedTime?.toISOString()}>
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
{zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
</time>
</Title>
{(isAdvanced || options.showDate) && (
{options.showDate && (
<Text className="clock-date-text" size={sizing} lineClamp={1}>
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
</Text>
)}
{isAdvanced && (
<Text size="xs" c="dimmed" lineClamp={1}>
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
{zonedTime?.format(options.dateFormat)}
</Text>
)}
</Stack>
@@ -73,117 +79,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
);
}
interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
locationName: string;
isFahrenheit: boolean;
showDetails: boolean;
}
const ClockWeatherCorner = ({
latitude,
longitude,
locationName,
isFahrenheit,
showDetails,
}: ClockWeatherCornerProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) {
return (
<Box pos="absolute" top={4} left={4}>
{isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
)}
</Box>
);
}
const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
const today = weather.daily[0];
const formatTemperature = (value: number | undefined) => {
if (value === undefined) return "—";
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
return `${Math.round(preferred)}${unit}`;
};
return (
<Stack
className="clock-weather-corner"
pos="absolute"
top={4}
left={4}
gap={showDetails ? 4 : 0}
align="flex-start"
>
<Group gap={4} wrap="nowrap">
<AnimatedWeatherIcon code={weather.current.weathercode} size={22} />
<Text className="clock-weather-corner-temp" size="xs" c="dimmed">
{Math.round(temp)}
{unit}
</Text>
{showDetails && <WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />}
</Group>
{showDetails && (
<Stack gap={1}>
<Text size="xs" fw={600}>
{locationName}
</Text>
<Text size="xs" c="dimmed">
{formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
</Text>
<Text size="xs" c="dimmed">
{Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
{isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
</Text>
<Text size="xs" c="dimmed">
{t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
{t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
</Text>
</Stack>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
interface UseCurrentTimeProps {
showSeconds: boolean;
}
const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
const [time, setTime] = useState<Date | null>(null);
const timeoutRef = useRef<NodeJS.Timeout>(null);
const intervalRef = useRef<NodeJS.Timeout>(null);
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
useEffect(() => {
setTime(new Date());
timeoutRef.current = setTimeout(
() => {
setTime(new Date());
intervalRef.current = setInterval(() => {
setTime(new Date());
}, intervalMultiplier * 1000);
},
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [intervalMultiplier, showSeconds]);
return time;
const getTitleOrder = (sizing: "xs" | "sm" | "md") => {
if (sizing === "md") return 2;
if (sizing === "sm") return 4;
return 6;
};
+19
View File
@@ -0,0 +1,19 @@
export const automaticTimeFormat = "automatic";
export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean) => {
if (format && format !== automaticTimeFormat) return format;
if (legacyIs24HourFormat) return "HH:mm";
return "hh:mm A";
};
export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat: boolean) => {
if (!format || format === automaticTimeFormat) return !legacyIs24HourFormat;
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
return /h{1,2}/.test(formatWithoutLiterals);
};
export const clockTimeFormatShowsSeconds = (format: string) => {
if (!format || format === automaticTimeFormat) return false;
const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, "");
return /s{1,2}/.test(formatWithoutLiterals);
};
+46 -18
View File
@@ -3,9 +3,18 @@ import dayjs from "dayjs";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
import {
defaultWorldClockCities,
getTimeZoneOptions,
maximumWorldClockCities,
worldClockCityPresets,
} from "./world-clock";
const timeZoneOptions = getTimeZoneOptions();
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
supportsAdvancedFocus: true,
queryKey: [["widget", "weather", "atLocation"]],
queryMatcher: ({ input }, scope) => {
const location = scope.options.weatherLocation;
@@ -37,12 +46,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
options: timeZoneOptions,
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
@@ -52,24 +58,29 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
{ value: "YYYY-MM-DD", label: `${dayjs().format("YYYY-MM-DD")} · YYYY-MM-DD` },
{ value: "DD/MM/YYYY", label: `${dayjs().format("DD/MM/YYYY")} · DD/MM/YYYY` },
{ value: "MM/DD/YYYY", label: `${dayjs().format("MM/DD/YYYY")} · MM/DD/YYYY` },
{ value: "dddd, MMMM D", label: `${dayjs().format("dddd, MMMM D")} · dddd, MMMM D` },
{ value: "dddd, D MMMM", label: `${dayjs().format("dddd, D MMMM")} · dddd, D MMMM` },
{ value: "MMM D, YYYY", label: `${dayjs().format("MMM D, YYYY")} · MMM D, YYYY` },
{ value: "D MMM YYYY", label: `${dayjs().format("D MMM YYYY")} · D MMM YYYY` },
{ value: "MMM D", label: `${dayjs().format("MMM D")} · MMM D` },
{ value: "D MMM", label: `${dayjs().format("D MMM")} · D MMM` },
{ value: "DD/MM", label: `${dayjs().format("DD/MM")} · DD/MM` },
{ value: "MM/DD", label: `${dayjs().format("MM/DD")} · MM/DD` },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
customTimeFormat: factory.select({
options: [
{ value: "HH:mm", label: `${dayjs().format("HH:mm")} · HH:mm` },
{ value: "HH:mm:ss", label: `${dayjs().format("HH:mm:ss")} · HH:mm:ss` },
{ value: "h:mm A", label: `${dayjs().format("h:mm A")} · h:mm A` },
{ value: "h:mm:ss A", label: `${dayjs().format("h:mm:ss A")} · h:mm:ss A` },
],
defaultValue: "HH:mm",
withDescription: true,
}),
showWeather: factory.switch({
@@ -86,8 +97,22 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
isWeatherFormatFahrenheit: factory.switch({
defaultValue: false,
}),
animateWeatherIcon: factory.switch({
defaultValue: false,
withDescription: true,
}),
worldClockCities: factory.timezoneList({
defaultValue: defaultWorldClockCities.map((city) => ({ ...city })),
maxValues: maximumWorldClockCities,
presets: worldClockCityPresets,
timeZoneOptions,
withDescription: true,
}),
}),
{
is24HourFormat: {
shouldHide: () => true,
},
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
},
@@ -103,6 +128,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
isWeatherFormatFahrenheit: {
shouldHide: (options) => !options.showWeather,
},
animateWeatherIcon: {
shouldHide: (options) => !options.showWeather,
},
},
);
},
@@ -0,0 +1,106 @@
import { Badge, Box, Group, Progress, Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { TimeOfDayPhase } from "./world-clock";
interface TimeOfDayBarProps {
minuteOfDay: number;
phase: TimeOfDayPhase;
showLegend?: boolean;
showTicks?: boolean;
label: string;
}
const phaseSections = [
{ key: "nightStart", value: 20.833, color: "indigo.8" },
{ key: "dawn", value: 12.5, color: "orange.6" },
{ key: "day", value: 41.667, color: "cyan.6" },
{ key: "dusk", value: 12.5, color: "grape.6" },
{ key: "nightEnd", value: 12.5, color: "indigo.8" },
] as const;
const phaseLegend = [
{ key: "night", range: "00–05 · 21–24", color: "indigo.8" },
{ key: "dawn", range: "05–08", color: "orange.6" },
{ key: "day", range: "08–18", color: "cyan.6" },
{ key: "dusk", range: "18–21", color: "grape.6" },
] as const;
export const TimeOfDayBar = ({
minuteOfDay,
phase,
showLegend = false,
showTicks = false,
label,
}: TimeOfDayBarProps) => {
const t = useScopedI18n("widget.clock");
const progress = Math.max(0, Math.min(100, (minuteOfDay / 1440) * 100));
const remainingMinutes = Math.max(0, 1440 - Math.floor(minuteOfDay));
const remainingHours = Math.floor(remainingMinutes / 60);
const remainder = remainingMinutes % 60;
return (
<Stack component="figure" gap={6} m={0} aria-label={label}>
<Group justify="space-between" align="center" gap="xs" wrap="nowrap">
<Badge color={getPhaseColor(phase)} variant="dot" size="sm">
{t(`worldClock.phase.${phase}`)}
</Badge>
<Text size="xs" c="dimmed" ta="right">
{t("worldClock.dayProgress", {
percent: Math.round(progress),
hours: remainingHours,
minutes: remainder.toString().padStart(2, "0"),
})}
</Text>
</Group>
<Box pos="relative">
<Progress.Root size="md" radius="xl" aria-hidden>
{phaseSections.map((section) => (
<Progress.Section key={section.key} value={section.value} color={section.color} withAria={false} />
))}
</Progress.Root>
<Box
pos="absolute"
top="50%"
left={`${progress}%`}
w={4}
h={24}
bg="var(--mantine-color-text)"
style={{ borderRadius: "var(--mantine-radius-xl)", transform: "translate(-50%, -50%)" }}
/>
</Box>
{showTicks && (
<Group justify="space-between" wrap="nowrap">
{["00", "06", "12", "18", "24"].map((tick) => (
<Text key={tick} size="xs" c="dimmed" aria-hidden>
{tick}
</Text>
))}
</Group>
)}
{showLegend && (
<Group gap="md" mt={4} align="center" wrap="wrap">
<Text size="xs" fw={600}>
{t("worldClock.phaseLegend")}
</Text>
{phaseLegend.map((item) => (
<Group key={item.key} gap={6} wrap="nowrap">
<Box w={9} h={9} bg={item.color} style={{ borderRadius: "var(--mantine-radius-xl)" }} />
<Text size="xs" c="dimmed">
{t(`worldClock.phase.${item.key}`)} · {item.range}
</Text>
</Group>
))}
</Group>
)}
</Stack>
);
};
const getPhaseColor = (phase: TimeOfDayPhase) => {
if (phase === "night") return "indigo";
if (phase === "dawn") return "orange";
if (phase === "day") return "cyan";
return "grape";
};
@@ -0,0 +1,27 @@
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => {
const [time, setTime] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>(null);
const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]);
useEffect(() => {
setTime(new Date());
timeoutRef.current = setTimeout(
() => {
setTime(new Date());
intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000);
},
intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()),
);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [intervalMultiplier, showSeconds]);
return time;
};
@@ -0,0 +1,78 @@
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { AnimatedWeatherIcon } from "../weather/animated-icon";
import { WeatherDescription } from "../weather/icon";
interface ClockWeatherSummaryProps {
latitude: number;
longitude: number;
locationName: string;
isFahrenheit: boolean;
animateIcon: boolean;
detailed: boolean;
}
export const ClockWeatherSummary = ({
latitude,
longitude,
locationName,
isFahrenheit,
animateIcon,
detailed,
}: ClockWeatherSummaryProps) => {
const t = useI18n();
const tCommon = useScopedI18n("common");
const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
const weather = weatherQuery.data;
if (!weather) {
return isInitialWidgetQueryPending(weatherQuery) ? (
<Loader size="xs" aria-label={tCommon("action.loading")} />
) : (
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
);
}
const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature;
const unit = isFahrenheit ? "°F" : "°C";
const today = weather.daily[0];
const formatTemperature = (value: number | null | undefined) => {
if (value === undefined || value === null) return "—";
const preferred = isFahrenheit ? value * (9 / 5) + 32 : value;
return `${Math.round(preferred)}${unit}`;
};
return (
<Stack gap={detailed ? 3 : 0} align="flex-start">
<Group gap={5} wrap="nowrap">
<AnimatedWeatherIcon
animated={animateIcon}
code={weather.current.weatherCode}
isDay={weather.current.isDay}
size={detailed ? 28 : 22}
/>
<Text size={detailed ? "sm" : "xs"} fw={detailed ? 600 : undefined} c={detailed ? undefined : "dimmed"}>
{Math.round(temperature)}
{unit}
</Text>
{detailed && <WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />}
</Group>
{detailed && (
<Box>
<Text size="xs" fw={600} lineClamp={1}>
{locationName}
</Text>
<Text size="xs" c="dimmed">
{formatTemperature(today?.maxTemperature)} / {formatTemperature(today?.minTemperature)}
</Text>
</Box>
)}
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("widget.weather.name")} />
</Stack>
);
};
@@ -0,0 +1,63 @@
import dayjs from "dayjs";
import { describe, expect, test } from "vitest";
import {
createWorldClockTime,
formatRelativeOffset,
formatUtcOffset,
getTimeOfDayPhase,
getTimeZoneOptions,
} from "./world-clock";
describe("world clock helpers", () => {
test("adds UTC to the available timezone options", () => {
expect(getTimeZoneOptions()).toContainEqual({ value: "UTC", label: "UTC (UTC)" });
});
test("classifies the fixed time-of-day phases at their boundaries", () => {
expect([0, 4, 5, 7, 8, 17, 18, 20, 21, 23].map(getTimeOfDayPhase)).toEqual([
"night",
"night",
"dawn",
"dawn",
"day",
"day",
"dusk",
"dusk",
"night",
"night",
]);
});
test("formats whole-hour and fractional offsets", () => {
expect(formatUtcOffset(345)).toBe("UTC+05:45");
expect(formatRelativeOffset(-210)).toBe("−3h 30m");
expect(formatRelativeOffset(0)).toBe("±0h");
});
test("compares civil dates across the date line from one instant", () => {
const now = new Date("2026-01-01T02:30:00.000Z");
const primaryTime = dayjs(now).tz("America/New_York");
const tokyo = createWorldClockTime({
city: { id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" },
now,
primaryTime,
});
expect(primaryTime.format("YYYY-MM-DD")).toBe("2025-12-31");
expect(tokyo.zonedTime?.format("YYYY-MM-DD")).toBe("2026-01-01");
expect(tokyo.dayDifference).toBe(1);
});
test("isolates unsupported timezones", () => {
const now = new Date("2026-01-01T00:00:00.000Z");
const result = createWorldClockTime({
city: { id: "invalid", label: "Invalid", timeZone: "Invalid/Timezone" },
now,
primaryTime: dayjs(now).tz("UTC"),
});
expect(result.zonedTime).toBeNull();
expect(result.utcOffset).toBeNull();
});
});
+127
View File
@@ -0,0 +1,127 @@
import dayjs from "dayjs";
import type { Dayjs } from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { OptionTimezone } from "../options";
dayjs.extend(utc);
dayjs.extend(timezone);
export const maximumWorldClockCities = 6;
export const worldClockCityPresets = [
{ id: "new-york", label: "New York", timeZone: "America/New_York" },
{ id: "london", label: "London", timeZone: "Europe/London" },
{ id: "paris", label: "Paris", timeZone: "Europe/Paris" },
{ id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" },
{ id: "sydney", label: "Sydney", timeZone: "Australia/Sydney" },
{ id: "utc", label: "UTC", timeZone: "UTC" },
] as const satisfies readonly OptionTimezone[];
export const defaultWorldClockCities = worldClockCityPresets.filter(({ id }) =>
["new-york", "paris", "tokyo"].includes(id),
);
export const getTimeZoneOptions = () => {
const timeZones = Intl.supportedValuesOf("timeZone");
if (!timeZones.includes("UTC")) timeZones.push("UTC");
return timeZones.map((timeZone) => ({
value: timeZone,
label: `${humanizeTimeZone(timeZone)} (${timeZone})`,
}));
};
export const humanizeTimeZone = (timeZone: string) => {
if (timeZone === "UTC") return "UTC";
const segments = timeZone.split("/");
return (segments.at(-1) ?? timeZone).replaceAll("_", " ");
};
export const isTimeZoneSupported = (timeZone: string) => {
try {
new Intl.DateTimeFormat("en", { timeZone }).format(0);
return true;
} catch {
return false;
}
};
export type TimeOfDayPhase = "night" | "dawn" | "day" | "dusk";
export interface WorldClockTime {
id: string;
label: string;
timeZone: string;
zonedTime: Dayjs | null;
phase: TimeOfDayPhase | null;
minuteOfDay: number | null;
utcOffset: number | null;
dayDifference: number | null;
}
export const getResolvedLocalTimeZone = () => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC";
};
export const createWorldClockTime = ({
city,
now,
primaryTime,
}: {
city: OptionTimezone;
now: Date;
primaryTime: Dayjs;
}): WorldClockTime => {
if (!isTimeZoneSupported(city.timeZone)) {
return {
...city,
zonedTime: null,
phase: null,
minuteOfDay: null,
utcOffset: null,
dayDifference: null,
};
}
const zonedTime = dayjs(now).tz(city.timeZone);
return {
...city,
zonedTime,
phase: getTimeOfDayPhase(zonedTime.hour()),
minuteOfDay: zonedTime.hour() * 60 + zonedTime.minute() + zonedTime.second() / 60,
utcOffset: zonedTime.utcOffset(),
dayDifference: getCivilDayNumber(zonedTime) - getCivilDayNumber(primaryTime),
};
};
export const getTimeOfDayPhase = (hour: number): TimeOfDayPhase => {
if (hour < 5 || hour >= 21) return "night";
if (hour < 8) return "dawn";
if (hour < 18) return "day";
return "dusk";
};
export const formatUtcOffset = (minutes: number) => {
if (minutes === 0) return "UTC±00:00";
const sign = minutes < 0 ? "−" : "+";
const absoluteMinutes = Math.abs(minutes);
const hours = Math.floor(absoluteMinutes / 60)
.toString()
.padStart(2, "0");
const remainingMinutes = (absoluteMinutes % 60).toString().padStart(2, "0");
return `UTC${sign}${hours}:${remainingMinutes}`;
};
export const formatRelativeOffset = (minutes: number) => {
if (minutes === 0) return "±0h";
const sign = minutes < 0 ? "−" : "+";
const absoluteMinutes = Math.abs(minutes);
const hours = Math.floor(absoluteMinutes / 60);
const remainingMinutes = absoluteMinutes % 60;
return remainingMinutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${remainingMinutes}m`;
};
const getCivilDayNumber = (value: Dayjs) => Date.UTC(value.year(), value.month(), value.date()) / 86_400_000;
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { addRememberedBrowserAlert, requestBrowserNotificationPermission } from "./browser-alert";
const originalNotification = window.Notification;
afterEach(() => {
if (originalNotification === undefined) {
Reflect.deleteProperty(window, "Notification");
return;
}
Object.defineProperty(window, "Notification", { configurable: true, value: originalNotification });
});
describe("browser alert occurrence history", () => {
it("moves a repeated occurrence to the end without duplicating it", () => {
expect(addRememberedBrowserAlert(["first", "second"], "first")).toEqual(["second", "first"]);
});
it("bounds remembered occurrences", () => {
const values = Array.from({ length: 110 }, (_, index) => String(index));
const result = addRememberedBrowserAlert(values, "next");
expect(result).toHaveLength(100);
expect(result.at(-1)).toBe("next");
});
it("keeps the current permission when the browser rejects a permission request", async () => {
Object.defineProperty(window, "Notification", {
configurable: true,
value: {
permission: "default",
requestPermission: vi.fn().mockRejectedValue(new Error("blocked")),
},
});
await expect(requestBrowserNotificationPermission()).resolves.toBe("default");
});
});
@@ -0,0 +1,87 @@
export type BrowserNotificationPermission = NotificationPermission | "unsupported";
export interface BrowserNotificationOptions {
title: string;
body?: string;
icon?: string;
tag?: string;
}
const maxRememberedOccurrences = 100;
export const getBrowserNotificationPermission = (): BrowserNotificationPermission => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return window.Notification.permission;
};
export const requestBrowserNotificationPermission = async (): Promise<BrowserNotificationPermission> => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
try {
return await window.Notification.requestPermission();
} catch {
return getBrowserNotificationPermission();
}
};
export const showBrowserNotification = ({ title, body, icon, tag }: BrowserNotificationOptions): boolean => {
if (typeof window === "undefined" || !("Notification" in window) || window.Notification.permission !== "granted") {
return false;
}
try {
const notification = new window.Notification(title, { body, icon, tag });
void notification;
return true;
} catch {
return false;
}
};
export const playBrowserAlertSound = (): boolean => {
if (typeof window === "undefined") return false;
const AudioContextConstructor =
window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextConstructor) return false;
try {
const context = new AudioContextConstructor();
const oscillator = context.createOscillator();
const gain = context.createGain();
const start = context.currentTime;
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(880, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.3);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(start);
oscillator.stop(start + 0.32);
oscillator.addEventListener("ended", () => void context.close(), { once: true });
return true;
} catch {
return false;
}
};
export const addRememberedBrowserAlert = (occurrences: string[], occurrence: string): string[] => {
const withoutDuplicate = occurrences.filter((value) => value !== occurrence);
return [...withoutDuplicate, occurrence].slice(-maxRememberedOccurrences);
};
export const claimBrowserAlertOccurrence = (storageKey: string, occurrence: string): boolean => {
if (typeof window === "undefined") return false;
try {
const serialized = window.localStorage.getItem(storageKey);
const parsed: unknown = serialized === null ? [] : JSON.parse(serialized);
const occurrences = Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === "string")
: [];
if (occurrences.includes(occurrence)) return false;
window.localStorage.setItem(storageKey, JSON.stringify(addRememberedBrowserAlert(occurrences, occurrence)));
return true;
} catch {
return false;
}
};
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseLocalWidgetState, serializeLocalWidgetState } from "./use-local-widget-state";
const isCounterState = (value: unknown): value is { count: number } =>
typeof value === "object" && value !== null && "count" in value && typeof value.count === "number";
describe("local widget state serialization", () => {
it("round trips validated state", () => {
const serialized = serializeLocalWidgetState(1, { count: 3 });
expect(parseLocalWidgetState(serialized, 1, isCounterState)).toEqual({ count: 3 });
});
it("rejects malformed and invalid state", () => {
expect(parseLocalWidgetState("not-json", 1, isCounterState)).toBeUndefined();
expect(parseLocalWidgetState('{"version":1,"value":{"count":"3"}}', 1, isCounterState)).toBeUndefined();
});
it("uses the supplied migration for old versions", () => {
const serialized = serializeLocalWidgetState(1, { oldCount: 4 });
expect(
parseLocalWidgetState(serialized, 2, isCounterState, (value) => {
if (typeof value !== "object" || value === null || !("oldCount" in value)) return undefined;
return { count: Number(value.oldCount) };
}),
).toEqual({ count: 4 });
});
});
@@ -0,0 +1,145 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface StoredWidgetState {
version: number;
value: unknown;
}
interface UseLocalWidgetStateOptions<T> {
key: string | undefined;
version: number;
defaultValue: T | (() => T);
validate?: (value: unknown) => value is T;
migrate?: (value: unknown, storedVersion: number) => T | undefined;
}
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
typeof value === "function" ? (value as () => T)() : value;
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
export const parseLocalWidgetState = <T>(
serialized: string,
version: number,
validate?: (value: unknown) => value is T,
migrate?: (value: unknown, storedVersion: number) => T | undefined,
): T | undefined => {
try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
return undefined;
}
const stored = parsed as StoredWidgetState;
if (typeof stored.version !== "number") return undefined;
if (stored.version !== version) {
const migrated = migrate?.(stored.value, stored.version);
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
return migrated;
}
if (validate && !validate(stored.value)) return undefined;
return stored.value as T;
} catch {
return undefined;
}
};
export const useLocalWidgetState = <T>({
key,
version,
defaultValue,
validate,
migrate,
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
validateRef.current = validate;
migrateRef.current = migrate;
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
const [value, setValueState] = useState<T>(createDefaultValue);
const valueRef = useRef(value);
const setMemoryValue = useCallback((nextValue: T) => {
valueRef.current = nextValue;
setValueState(nextValue);
}, []);
const removeStoredValue = useCallback(() => {
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [key]);
useEffect(() => {
if (!key) {
setMemoryValue(createDefaultValue());
return;
}
try {
const serialized = window.localStorage.getItem(key);
if (serialized === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
if (parsed === undefined) {
window.localStorage.removeItem(key);
setMemoryValue(createDefaultValue());
return;
}
setMemoryValue(parsed);
} catch {
setMemoryValue(createDefaultValue());
}
}, [createDefaultValue, key, setMemoryValue, version]);
useEffect(() => {
if (!key) return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== key) return;
if (event.newValue === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
if (parsed !== undefined) setMemoryValue(parsed);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [createDefaultValue, key, setMemoryValue, version]);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(nextValue) => {
const resolved =
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
if (Object.is(resolved, valueRef.current)) return;
setMemoryValue(resolved);
if (key) {
try {
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
} catch {
// Keep the in-memory state usable when persistence is unavailable.
}
}
},
[key, setMemoryValue, version],
);
const resetValue = useCallback(() => {
removeStoredValue();
setMemoryValue(createDefaultValue());
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
return [value, setValue, resetValue];
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getNextWidgetTickDelay } from "./use-widget-now";
describe("getNextWidgetTickDelay", () => {
it("aligns second and minute ticks", () => {
const now = new Date(2026, 7, 20, 12, 34, 45, 250);
expect(getNextWidgetTickDelay(now, "second")).toBe(750);
expect(getNextWidgetTickDelay(now, "minute")).toBe(14_750);
});
it("aligns day ticks to the next local midnight", () => {
const now = new Date(2026, 7, 20, 23, 59, 59, 500);
expect(getNextWidgetTickDelay(now, "day")).toBe(500);
});
});
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type WidgetClockPrecision = "second" | "minute" | "day";
const minimumDelayMs = 10;
export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecision): number => {
let next: number;
if (precision === "second") {
next = now.getTime() - now.getMilliseconds() + 1_000;
} else if (precision === "minute") {
next = now.getTime() - now.getSeconds() * 1_000 - now.getMilliseconds() + 60_000;
} else {
next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
}
return Math.max(minimumDelayMs, next - now.getTime());
};
export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => {
const [now, setNow] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
if (timeoutRef.current === undefined) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}, []);
const scheduleTick = useCallback(() => {
cancelTick();
const current = new Date();
setNow(current);
timeoutRef.current = setTimeout(scheduleTick, getNextWidgetTickDelay(current, precision));
}, [cancelTick, precision]);
useEffect(() => {
scheduleTick();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") scheduleTick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
cancelTick();
};
}, [cancelTick, scheduleTick]);
return now;
};
@@ -0,0 +1,42 @@
.root {
container-type: size;
overflow: hidden;
}
.duration {
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
}
.compactDuration {
font-size: var(--mantine-font-size-xl);
white-space: nowrap;
}
.compactProgress {
width: 100%;
max-width: 14rem;
}
.eventCard {
min-width: 0;
}
@container (max-width: 170px) {
.compactStatus,
.compactTarget {
display: none;
}
}
@container (max-width: 140px) {
.compactDuration {
font-size: clamp(var(--mantine-font-size-sm), 15cqw, var(--mantine-font-size-md));
}
}
@container (max-height: 105px) {
.compactTarget {
display: none;
}
}
@@ -0,0 +1,361 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
Group,
Paper,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Switch,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconBell, IconBellOff, IconHourglass } from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import {
claimBrowserAlertOccurrence,
getBrowserNotificationPermission,
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { formatCountdownDuration } from "./display";
import styles from "./component.module.css";
import { getCountdownEventState, getCountdownProgress, groupCountdownStates } from "./recurrence";
import type { CountdownEventState } from "./recurrence";
interface AlertPreferences {
notifications: boolean;
}
const defaultAlertPreferences: AlertPreferences = { notifications: false };
const isAlertPreferences = (value: unknown): value is AlertPreferences => {
if (typeof value !== "object" || value === null) return false;
return "notifications" in value && typeof value.notifications === "boolean";
};
const migrateAlertPreferences = (value: unknown, storedVersion: number): AlertPreferences | undefined => {
if (storedVersion !== 1 || !isAlertPreferences(value)) return undefined;
return { notifications: value.notifications };
};
export default function CountdownWidget({
boardId,
displayMode,
isEditMode,
itemId,
options,
width,
}: WidgetComponentProps<"countdown">) {
const now = useWidgetNow(options.showSeconds ? "second" : "minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.countdown");
const storagePrefix = boardId && itemId ? `homarr:countdown:${boardId}:${itemId}` : undefined;
const [preferences, setPreferences] = useLocalWidgetState<AlertPreferences>({
key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined,
version: 2,
defaultValue: defaultAlertPreferences,
validate: isAlertPreferences,
migrate: migrateAlertPreferences,
});
const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission());
const previousNowRef = useRef<Date | null>(null);
const states = useMemo(() => {
if (!now) return [];
return options.events.map((event) => getCountdownEventState(event, now));
}, [now, options.events]);
const grouped = useMemo(() => groupCountdownStates(states), [states]);
useEffect(() => {
if (!now || !storagePrefix) return;
const previousNow = previousNowRef.current;
previousNowRef.current = now;
if (!previousNow || !preferences.notifications) return;
states.forEach((state) => {
const occurrence = state.previousOccurrence;
if (!occurrence || occurrence <= previousNow || occurrence > now) return;
const claimKey = `${storagePrefix}:alerted-occurrences`;
const occurrenceKey = `${state.event.id}:${occurrence.toISOString()}`;
if (!claimBrowserAlertOccurrence(claimKey, occurrenceKey)) return;
showBrowserNotification({
title: state.event.label,
body: t("alert.reached"),
tag: occurrenceKey,
});
});
}, [now, preferences.notifications, states, storagePrefix, t]);
if (!now) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const hero = states[0];
if (!hero) {
return (
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
<ThemeIcon variant="light" size="xl" radius="xl">
<IconHourglass />
</ThemeIcon>
<Text fw={600}>{t("empty.title")}</Text>
<Text size="sm" c="dimmed" ta="center">
{t("empty.description")}
</Text>
</Stack>
);
}
let heroStatus: string | null = null;
if (hero.invalid) heroStatus = t("invalid.value");
else if (hero.completed) heroStatus = t("completed");
if (displayMode !== "advanced") {
return (
<Stack className={styles.root} h="100%" justify="center" align="center" gap={6} p="sm">
<Group gap="xs" wrap="nowrap" maw="100%">
{heroStatus && (
<Badge className={styles.compactStatus} variant="outline" size="xs" flex="0 0 auto">
{heroStatus}
</Badge>
)}
<Text size="sm" fw={600} truncate>
{hero.event.label}
</Text>
</Group>
<CountdownValue state={hero} now={now} locale={locale} showSeconds={options.showSeconds} compact />
<Text className={styles.compactTarget} size="xs" c="dimmed" truncate maw="100%">
{formatOccurrence(hero, locale)}
</Text>
{options.showProgress && (
<ProgressBar className={styles.compactProgress} state={hero} now={now} label={t("progress")} />
)}
</Stack>
);
}
const setNotifications = async (enabled: boolean) => {
if (!enabled) {
setPreferences((current) => ({ ...current, notifications: false }));
return;
}
const permission = await requestBrowserNotificationPermission();
setNotificationPermission(permission);
if (permission === "granted") setPreferences((current) => ({ ...current, notifications: true }));
};
const eventColumns = width >= 700 ? 2 : 1;
return (
<Stack className={styles.root} h="100%" gap="md" p="md">
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
<Group gap="xs" wrap="nowrap">
{heroStatus && <Badge variant="outline">{heroStatus}</Badge>}
<Text size="sm" c="dimmed" truncate>
{formatOccurrence(hero, locale)}
</Text>
</Group>
<Title order={2} lineClamp={1}>
{hero.event.label}
</Title>
<CountdownValue state={hero} now={now} locale={locale} showSeconds={options.showSeconds} />
</Stack>
{options.showProgress && <ProgressBar state={hero} now={now} label={t("progress")} />}
</Paper>
<AlertControls
disabled={isEditMode}
persistent={storagePrefix !== undefined}
preferences={preferences}
notificationPermission={notificationPermission}
onNotificationsChange={setNotifications}
/>
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}>
<Stack gap="md" pb="xs">
<EventSection
title={t("upcoming")}
states={grouped.upcoming}
now={now}
locale={locale}
showSeconds={options.showSeconds}
showProgress={options.showProgress}
columns={eventColumns}
/>
<EventSection
title={t("completedEvents")}
states={grouped.completed}
now={now}
locale={locale}
showSeconds={options.showSeconds}
showProgress={options.showProgress}
columns={eventColumns}
/>
{grouped.invalid.length > 0 && (
<Alert color="yellow" title={t("invalid.title")}>
{t("invalid.description", { count: grouped.invalid.length })}
</Alert>
)}
</Stack>
</ScrollArea>
</Stack>
);
}
const CountdownValue = ({
state,
now,
locale,
showSeconds,
compact = false,
}: {
state: CountdownEventState;
now: Date;
locale: string;
showSeconds: boolean;
compact?: boolean;
}) => {
const t = useScopedI18n("widget.countdown");
let value = t("invalid.value");
if (state.completed) value = t("completed");
else if (state.nextOccurrence)
value = formatCountdownDuration(state, now, showSeconds, locale, compact ? "narrow" : "short");
return (
<Text
className={compact ? `${styles.duration} ${styles.compactDuration}` : styles.duration}
fz={compact ? undefined : 42}
fw={750}
lh={1.05}
ta={compact ? "center" : undefined}
>
{value}
</Text>
);
};
const ProgressBar = ({
className,
state,
now,
label,
}: {
className?: string;
state: CountdownEventState;
now: Date;
label: string;
}) => {
const progress = getCountdownProgress(state, now);
if (progress === null) return null;
return (
<Progress
className={className}
mt="xs"
value={progress}
aria-label={`${label}: ${Math.round(progress)}%`}
animated={false}
/>
);
};
const EventSection = ({
title,
states,
now,
locale,
showSeconds,
showProgress,
columns,
}: {
title: string;
states: CountdownEventState[];
now: Date;
locale: string;
showSeconds: boolean;
showProgress: boolean;
columns: number;
}) => {
const t = useScopedI18n("widget.countdown");
if (states.length === 0) return null;
return (
<Stack gap="xs">
<Text fw={700}>{title}</Text>
<SimpleGrid cols={columns} spacing="xs">
{states.map((state) => (
<Paper className={styles.eventCard} key={state.event.id} withBorder p="sm">
<Stack gap={4} h="100%">
<Group justify="space-between" wrap="nowrap">
<Text fw={600} truncate>
{state.event.label}
</Text>
{state.event.recurrence === "yearly" && <Badge variant="outline">{t("yearly")}</Badge>}
</Group>
<CountdownValue state={state} now={now} locale={locale} showSeconds={showSeconds} compact />
<Stack gap={4} mt="auto">
{showProgress && <ProgressBar state={state} now={now} label={title} />}
<Text size="xs" c="dimmed">
{formatOccurrence(state, locale)}
</Text>
</Stack>
</Stack>
</Paper>
))}
</SimpleGrid>
</Stack>
);
};
const AlertControls = ({
disabled,
persistent,
preferences,
notificationPermission,
onNotificationsChange,
}: {
disabled: boolean;
persistent: boolean;
preferences: AlertPreferences;
notificationPermission: ReturnType<typeof getBrowserNotificationPermission>;
onNotificationsChange: (enabled: boolean) => Promise<void>;
}) => {
const t = useScopedI18n("widget.countdown.alert");
if (!persistent) return null;
return (
<Paper withBorder p="sm" radius="md">
<Switch
checked={preferences.notifications && notificationPermission === "granted"}
disabled={disabled || notificationPermission === "unsupported" || notificationPermission === "denied"}
onChange={(event) => void onNotificationsChange(event.currentTarget.checked)}
label={t("notifications")}
thumbIcon={preferences.notifications ? <IconBell size={12} /> : <IconBellOff size={12} />}
/>
{notificationPermission === "denied" && (
<Text size="xs" c="dimmed" mt="xs">
{t("permissionDenied")}
</Text>
)}
</Paper>
);
};
const formatOccurrence = (state: CountdownEventState, locale: string) => {
const occurrence = state.nextOccurrence ?? state.previousOccurrence;
if (!occurrence) return state.event.timeZone;
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: state.event.timeZone,
}).format(occurrence);
};
+64
View File
@@ -0,0 +1,64 @@
import type { CountdownEventState } from "./recurrence";
const secondMs = 1_000;
const minuteMs = 60 * secondMs;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
export interface CountdownDurationPart {
unit: Intl.NumberFormatOptions["unit"];
value: number;
}
export const getCountdownDurationParts = (
state: CountdownEventState,
now: Date,
showSeconds: boolean,
): CountdownDurationPart[] => {
const occurrence = state.nextOccurrence;
if (!occurrence) return [];
const remaining = Math.max(0, occurrence.getTime() - now.getTime());
const days = Math.floor(remaining / dayMs);
const hours = Math.floor((remaining % dayMs) / hourMs);
const minutes = Math.floor((remaining % hourMs) / minuteMs);
const seconds = Math.floor((remaining % minuteMs) / secondMs);
if (showSeconds) {
const parts: CountdownDurationPart[] = [];
if (days > 0) parts.push({ unit: "day", value: days });
if (days > 0 || hours > 0) parts.push({ unit: "hour", value: hours });
if (days > 0 || hours > 0 || minutes > 0) parts.push({ unit: "minute", value: minutes });
parts.push({ unit: "second", value: seconds });
return parts;
}
if (days > 0)
return compactParts([
{ unit: "day", value: days },
{ unit: "hour", value: hours },
]);
if (hours > 0)
return compactParts([
{ unit: "hour", value: hours },
{ unit: "minute", value: minutes },
]);
if (minutes > 0) return [{ unit: "minute", value: minutes }];
return [{ unit: "minute", value: 0 }];
};
const compactParts = (parts: CountdownDurationPart[]) => {
const nonZero = parts.filter(({ value }) => value > 0);
return nonZero.length > 0 ? nonZero : parts.slice(0, 1);
};
export const formatCountdownDuration = (
state: CountdownEventState,
now: Date,
showSeconds: boolean,
locale: string,
unitDisplay: Intl.NumberFormatOptions["unitDisplay"] = "short",
) => {
return getCountdownDurationParts(state, now, showSeconds)
.map(({ unit, value }) => new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay }).format(value))
.join(" ");
};
+24
View File
@@ -0,0 +1,24 @@
import { IconHourglass } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { getTimeZoneOptions } from "../clock/world-clock";
const maximumCountdownEvents = 20;
export const { definition, componentLoader } = createWidgetDefinition("countdown", {
icon: IconHourglass,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
events: factory.dateTimeEventList({
defaultValue: [],
maxValues: maximumCountdownEvents,
timeZoneOptions: getTimeZoneOptions(),
withDescription: true,
}),
showProgress: factory.switch({ defaultValue: true }),
showSeconds: factory.switch({ defaultValue: false, withDescription: true }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { OptionDateTimeEvent } from "../options";
import { getCountdownDurationParts } from "./display";
import {
getCountdownEventState,
getCountdownProgress,
resolveWallDateTimeString,
groupCountdownStates,
} from "./recurrence";
const event = (overrides: Partial<OptionDateTimeEvent> = {}): OptionDateTimeEvent => ({
id: "event",
label: "Event",
targetUtc: "2026-07-15T13:00:00.000Z",
timeZone: "America/New_York",
recurrence: "none",
...overrides,
});
describe("countdown recurrence", () => {
it("preserves a yearly event's wall-clock time across DST offsets", () => {
const recurring = event({ recurrence: "yearly" });
const winterState = getCountdownEventState(recurring, new Date("2027-01-01T00:00:00.000Z"));
expect(winterState.nextOccurrence?.toISOString()).toBe("2027-07-15T13:00:00.000Z");
});
it("skips non-leap years for a February 29 recurrence", () => {
const recurring = event({
recurrence: "yearly",
targetUtc: "2024-02-29T12:00:00.000Z",
timeZone: "UTC",
});
const state = getCountdownEventState(recurring, new Date("2025-01-01T00:00:00.000Z"));
expect(state.nextOccurrence?.toISOString()).toBe("2028-02-29T12:00:00.000Z");
expect(state.previousOccurrence?.toISOString()).toBe("2024-02-29T12:00:00.000Z");
});
it("selects the first valid time after a DST gap", () => {
const resolved = resolveWallDateTimeString("2026-03-08 02:30:45", "America/New_York");
expect(resolved?.toISOString()).toBe("2026-03-08T07:00:00.000Z");
});
it("selects the earlier occurrence when a wall-clock time repeats", () => {
const resolved = resolveWallDateTimeString("2026-11-01 01:30:00", "America/New_York");
expect(resolved?.toISOString()).toBe("2026-11-01T05:30:00.000Z");
});
});
describe("countdown ordering and progress", () => {
const now = new Date("2026-06-01T12:00:00.000Z");
it("preserves configured order within event groups", () => {
const states = [
getCountdownEventState(event({ id: "later", targetUtc: "2026-06-03T12:00:00.000Z" }), now),
getCountdownEventState(event({ id: "older", targetUtc: "2026-05-01T12:00:00.000Z" }), now),
getCountdownEventState(event({ id: "sooner", targetUtc: "2026-06-02T12:00:00.000Z" }), now),
getCountdownEventState(event({ id: "newer", targetUtc: "2026-05-31T12:00:00.000Z" }), now),
];
const grouped = groupCountdownStates(states);
expect(grouped.upcoming.map(({ event: item }) => item.id)).toEqual(["later", "sooner"]);
expect(grouped.completed.map(({ event: item }) => item.id)).toEqual(["older", "newer"]);
});
it("clamps one-time progress and omits it without a start", () => {
const withStart = getCountdownEventState(
event({ targetUtc: "2026-06-02T12:00:00.000Z", startUtc: "2026-05-31T12:00:00.000Z" }),
now,
);
const withoutStart = getCountdownEventState(event({ targetUtc: "2026-06-02T12:00:00.000Z" }), now);
expect(getCountdownProgress(withStart, now)).toBe(50);
expect(getCountdownProgress(withStart, new Date("2026-06-04T12:00:00.000Z"))).toBe(100);
expect(getCountdownProgress(withoutStart, now)).toBeNull();
});
it("uses the previous and next occurrence for yearly progress", () => {
const state = getCountdownEventState(
event({ recurrence: "yearly", targetUtc: "2024-01-01T00:00:00.000Z", timeZone: "UTC" }),
new Date("2026-07-02T12:00:00.000Z"),
);
const progress = getCountdownProgress(state, new Date("2026-07-02T12:00:00.000Z"));
expect(progress).not.toBeNull();
if (progress === null) throw new Error("Expected yearly progress");
expect(progress).toBeGreaterThan(49);
expect(progress).toBeLessThan(51);
});
it("keeps seconds visible at every duration when enabled", () => {
const longState = getCountdownEventState(event({ targetUtc: "2026-06-03T13:00:00.000Z" }), now);
const shortState = getCountdownEventState(event({ targetUtc: "2026-06-02T11:30:45.000Z" }), now);
expect(getCountdownDurationParts(longState, now, true).some(({ unit }) => unit === "second")).toBe(true);
expect(getCountdownDurationParts(shortState, now, true).some(({ unit }) => unit === "second")).toBe(true);
expect(getCountdownDurationParts(longState, now, false).some(({ unit }) => unit === "second")).toBe(false);
});
});
@@ -0,0 +1,226 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import type { OptionDateTimeEvent } from "../options";
dayjs.extend(utc);
dayjs.extend(timezone);
const minuteMs = 60_000;
const dayMs = 86_400_000;
export interface WallDateTime {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
export interface CountdownEventState {
event: OptionDateTimeEvent;
nextOccurrence: Date | null;
previousOccurrence: Date | null;
completed: boolean;
invalid: boolean;
}
export const isCountdownTimeZoneSupported = (timeZone: string) => {
try {
new Intl.DateTimeFormat("en", { timeZone }).format(0);
return true;
} catch {
return false;
}
};
export const parseWallDateTime = (value: string): WallDateTime | null => {
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
if (!match) return null;
const parts: WallDateTime = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: Number(match[6] ?? 0),
};
const validationDate = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second),
);
if (
validationDate.getUTCFullYear() !== parts.year ||
validationDate.getUTCMonth() !== parts.month - 1 ||
validationDate.getUTCDate() !== parts.day ||
validationDate.getUTCHours() !== parts.hour ||
validationDate.getUTCMinutes() !== parts.minute ||
validationDate.getUTCSeconds() !== parts.second
) {
return null;
}
return parts;
};
export const formatWallDateTime = (instant: string | Date, timeZone: string) => {
if (!isCountdownTimeZoneSupported(timeZone)) return "";
const value = dayjs(instant);
if (!value.isValid()) return "";
return value.tz(timeZone).format("YYYY-MM-DD HH:mm:ss");
};
const wallDateTimeEquals = (value: WallDateTime, candidate: dayjs.Dayjs) =>
candidate.year() === value.year &&
candidate.month() + 1 === value.month &&
candidate.date() === value.day &&
candidate.hour() === value.hour &&
candidate.minute() === value.minute &&
candidate.second() === value.second;
const resolveExactWallDateTime = (value: WallDateTime, timeZone: string): Date[] => {
const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute, value.second);
const offsets = new Set<number>();
for (const dayOffset of [-2, -1, 0, 1, 2]) {
offsets.add(
dayjs
.utc(naiveUtc + dayOffset * dayMs)
.tz(timeZone)
.utcOffset(),
);
}
const matches: Date[] = [];
for (const offset of offsets) {
const candidate = new Date(naiveUtc - offset * minuteMs);
if (wallDateTimeEquals(value, dayjs(candidate).tz(timeZone))) matches.push(candidate);
}
return matches.toSorted((left, right) => left.getTime() - right.getTime());
};
/** Resolves a local wall-clock value, preferring the earlier repeated time and the first valid time after a DST gap. */
export const resolveWallDateTime = (value: WallDateTime, timeZone: string): Date | null => {
if (!isCountdownTimeZoneSupported(timeZone)) return null;
const exactMatches = resolveExactWallDateTime(value, timeZone);
if (exactMatches[0]) return exactMatches[0];
const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute);
for (let minuteOffset = 1; minuteOffset <= 180; minuteOffset += 1) {
const candidateWallDate = new Date(naiveUtc + minuteOffset * minuteMs);
const candidateWall: WallDateTime = {
year: candidateWallDate.getUTCFullYear(),
month: candidateWallDate.getUTCMonth() + 1,
day: candidateWallDate.getUTCDate(),
hour: candidateWallDate.getUTCHours(),
minute: candidateWallDate.getUTCMinutes(),
second: candidateWallDate.getUTCSeconds(),
};
const matches = resolveExactWallDateTime(candidateWall, timeZone);
if (matches[0]) return matches[0];
}
return null;
};
export const resolveWallDateTimeString = (value: string, timeZone: string) => {
const wallDateTime = parseWallDateTime(value);
if (!wallDateTime) return null;
return resolveWallDateTime(wallDateTime, timeZone);
};
const isLeapYear = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
const occurrenceForYear = (target: WallDateTime, year: number, timeZone: string) => {
if (target.month === 2 && target.day === 29 && !isLeapYear(year)) return null;
return resolveWallDateTime({ ...target, year }, timeZone);
};
const findYearlyOccurrence = (
target: WallDateTime,
timeZone: string,
startYear: number,
direction: 1 | -1,
predicate: (date: Date) => boolean,
) => {
for (let index = 0; index < 12; index += 1) {
const occurrence = occurrenceForYear(target, startYear + index * direction, timeZone);
if (occurrence && predicate(occurrence)) return occurrence;
}
return null;
};
export const getCountdownEventState = (event: OptionDateTimeEvent, now: Date): CountdownEventState => {
const target = dayjs(event.targetUtc);
if (!target.isValid() || !isCountdownTimeZoneSupported(event.timeZone)) {
return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true };
}
if (event.recurrence === "none") {
const targetDate = target.toDate();
const completed = targetDate.getTime() <= now.getTime();
return {
event,
nextOccurrence: completed ? null : targetDate,
previousOccurrence: completed ? targetDate : null,
completed,
invalid: false,
};
}
const targetWall = parseWallDateTime(formatWallDateTime(event.targetUtc, event.timeZone));
if (!targetWall) {
return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true };
}
const currentYear = dayjs(now).tz(event.timeZone).year();
const nextOccurrence = findYearlyOccurrence(
targetWall,
event.timeZone,
currentYear,
1,
(occurrence) => occurrence.getTime() > now.getTime(),
);
const previousOccurrence = findYearlyOccurrence(
targetWall,
event.timeZone,
currentYear,
-1,
(occurrence) => occurrence.getTime() <= now.getTime(),
);
return {
event,
nextOccurrence,
previousOccurrence,
completed: false,
invalid: nextOccurrence === null,
};
};
export const getCountdownProgress = (state: CountdownEventState, now: Date): number | null => {
let start: Date | null = null;
let end: Date | null = null;
if (state.event.recurrence === "yearly") {
start = state.previousOccurrence;
end = state.nextOccurrence;
} else if (state.event.startUtc) {
const parsedStart = dayjs(state.event.startUtc);
const parsedEnd = dayjs(state.event.targetUtc);
if (parsedStart.isValid() && parsedEnd.isValid()) {
start = parsedStart.toDate();
end = parsedEnd.toDate();
}
}
if (!start || !end || end.getTime() <= start.getTime()) return null;
const progress = ((now.getTime() - start.getTime()) / (end.getTime() - start.getTime())) * 100;
return Math.min(100, Math.max(0, progress));
};
export const groupCountdownStates = (states: CountdownEventState[]) => {
const upcoming = states.filter((state) => state.nextOccurrence !== null && !state.invalid);
const completed = states.filter((state) => state.completed && state.previousOccurrence !== null && !state.invalid);
const invalid = states.filter((state) => state.invalid);
return { upcoming, completed, invalid };
};
export const getLatestReachedOccurrence = (event: OptionDateTimeEvent, now: Date) =>
getCountdownEventState(event, now).previousOccurrence;
+8
View File
@@ -63,6 +63,10 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as today from "./today";
import * as airQuality from "./air-quality";
import * as countdown from "./countdown";
import * as timer from "./timer";
import * as customApi from "./custom-api";
import * as assistant from "./assistant";
import * as wud from "./wud";
@@ -94,6 +98,10 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
today,
airQuality,
countdown,
timer,
app,
archiveTeamWarrior,
anchorNote,
+4 -3
View File
@@ -17,6 +17,8 @@ import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
import { getWidgetQueryKeys } from "./definition";
const serializePollingPolicy = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry);
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
@@ -145,9 +147,8 @@ describe("widget manifest promise stability", () => {
}
}
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
expect(widgetQueryRefetchIntervals.map(serializePollingPolicy).toSorted()).toEqual(
[...expectedByQueryKey.values()].map(serializePollingPolicy).toSorted(),
);
});
+8
View File
@@ -24,6 +24,10 @@ export type WidgetResources = {
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
clock: () => import("./clock") as Promise<WidgetModule>,
weather: () => import("./weather") as Promise<WidgetModule>,
today: () => import("./today") as Promise<WidgetModule>,
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
countdown: () => import("./countdown") as Promise<WidgetModule>,
timer: () => import("./timer") as Promise<WidgetModule>,
app: () => import("./app") as Promise<WidgetModule>,
assistant: () => import("./assistant") as Promise<WidgetModule>,
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise<WidgetModule>,
@@ -85,6 +89,10 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
today: () => import("./today/component") as Promise<WidgetComponentModule>,
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
app: () => import("./app/component") as Promise<WidgetComponentModule>,
assistant: () => import("./assistant/component") as Promise<WidgetComponentModule>,
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise<WidgetComponentModule>,
+96
View File
@@ -52,6 +52,7 @@ interface SelectInput<TOptions extends readonly SelectOption[]> extends CommonIn
> {
options: TOptions;
searchable?: boolean;
withPlaceholder?: boolean;
}
interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
@@ -107,6 +108,34 @@ interface SliderInput extends CommonInput<number> {
step?: number;
}
export interface OptionTimezone {
id: string;
label: string;
timeZone: string;
}
interface TimezoneListInput extends CommonInput<OptionTimezone[]> {
maxValues?: number;
presets?: readonly OptionTimezone[];
timeZoneOptions: readonly { value: string; label: string }[];
}
export type DateTimeEventRecurrence = "none" | "yearly";
export interface OptionDateTimeEvent {
id: string;
label: string;
targetUtc: string;
timeZone: string;
startUtc?: string;
recurrence: DateTimeEventRecurrence;
}
interface DateTimeEventListInput extends CommonInput<OptionDateTimeEvent[]> {
maxValues?: number;
timeZoneOptions: readonly { value: string; label: string }[];
}
export interface OptionLocation {
name: string;
latitude: number;
@@ -163,6 +192,7 @@ const optionsFactory = {
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
withPlaceholder: input.withPlaceholder ?? false,
}),
number: (input: NumberInput) => ({
type: "number" as const,
@@ -192,6 +222,72 @@ const optionsFactory = {
longitude: z.number(),
}),
}),
timezoneList: (input: TimezoneListInput) => {
const maxValues = input.maxValues ?? 6;
return {
type: "timezoneList" as const,
defaultValue: input.defaultValue ?? [],
maxValues,
presets: input.presets ?? [],
timeZoneOptions: input.timeZoneOptions,
withDescription: input.withDescription ?? false,
validate: z
.array(
z.object({
id: z.string().min(1),
label: z.string().trim().min(1).max(64),
timeZone: z.string().min(1),
}),
)
.max(maxValues)
.superRefine((values, ctx) => {
const ids = new Set<string>();
const timeZones = new Set<string>();
values.forEach((value, index) => {
if (ids.has(value.id)) {
ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" });
}
if (timeZones.has(value.timeZone)) {
ctx.addIssue({ code: "custom", path: [index, "timeZone"], message: "Duplicate timezone" });
}
ids.add(value.id);
timeZones.add(value.timeZone);
});
}),
};
},
dateTimeEventList: (input: DateTimeEventListInput) => {
const maxValues = input.maxValues ?? 20;
const dateTimeEventSchema = z.object({
id: z.string().min(1),
label: z.string().trim().min(1).max(64),
targetUtc: z.iso.datetime(),
timeZone: z.string().min(1),
startUtc: z.iso.datetime().optional(),
recurrence: z.enum(["none", "yearly"]),
});
return {
type: "dateTimeEventList" as const,
defaultValue: input.defaultValue ?? [],
maxValues,
timeZoneOptions: input.timeZoneOptions,
withDescription: input.withDescription ?? false,
validate: z
.array(dateTimeEventSchema)
.max(maxValues)
.superRefine((values, ctx) => {
const ids = new Set<string>();
values.forEach((value, index) => {
if (ids.has(value.id)) {
ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" });
}
ids.add(value.id);
});
}),
};
},
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
type: "multiText" as const,
defaultValue: input?.defaultValue ?? [],
@@ -13,6 +13,8 @@ export const widgetQueryRefetchIntervals = [
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{ queryKey: [["widget", "weather", "atLocation"]], intervalSeconds: 600 },
{ queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
@@ -0,0 +1,73 @@
.root {
container-type: size;
overflow: hidden;
}
.hero {
background: linear-gradient(145deg, var(--mantine-primary-color-light), var(--mantine-color-secondaryColor-light));
}
.time {
font-variant-numeric: tabular-nums;
letter-spacing: -0.045em;
}
.root[data-needs-attention="true"],
.hero[data-needs-attention="true"] {
animation: timer-border-alert 300ms ease-out 5;
}
@keyframes timer-border-alert {
0% {
box-shadow:
inset 0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0),
0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0);
}
35% {
box-shadow:
inset 0 0 0 2px alpha(var(--mantine-color-secondaryColor-filled), 0.9),
0 0 22px 5px alpha(var(--mantine-color-secondaryColor-filled), 0.65);
}
100% {
box-shadow:
inset 0 0 0 1px alpha(var(--mantine-color-secondaryColor-filled), 0),
0 0 28px 7px alpha(var(--mantine-color-secondaryColor-filled), 0);
}
}
@media (prefers-reduced-motion: reduce) {
.root[data-needs-attention="true"],
.hero[data-needs-attention="true"] {
animation: none;
box-shadow: inset 0 0 0 2px var(--mantine-color-secondaryColor-filled);
}
}
@media (forced-colors: active) {
.root[data-needs-attention="true"],
.hero[data-needs-attention="true"] {
animation: none;
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
@container (max-height: 125px) {
.root [role="progressbar"] {
display: none;
}
}
@container (max-width: 210px) {
.completedFocus {
display: none;
}
}
@container (max-height: 110px) {
.phaseBadge {
display: none;
}
}
+504
View File
@@ -0,0 +1,504 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Group,
Paper,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Switch,
Text,
Tooltip,
} from "@mantine/core";
import { IconBell, IconPlayerPause, IconPlayerPlay, IconRefresh, IconSettings, IconVolume } from "@tabler/icons-react";
import { useTiks } from "@rexa-developer/tiks/react";
import { useScopedI18n } from "@homarr/translation/client";
import {
claimBrowserAlertOccurrence,
getBrowserNotificationPermission,
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
import {
createTimerRuntime,
getRemainingMs,
isTimerRuntimeState,
migrateTimerRuntimeState,
pauseTimerRuntime,
reconcileTimerRuntime,
resetTimerRuntime,
setTimerAlertPreference,
startTimerRuntime,
synchronizeTimerRuntime,
} from "./runtime";
import type { TimerDurations, TimerRuntimeState } from "./runtime";
const completionSoundCount = 5;
const completionSoundIntervalMs = 300;
export default function TimerWidget({
boardId,
displayMode,
height,
isEditMode,
itemId,
options,
width,
}: WidgetComponentProps<"timer">) {
const now = useWidgetNow("second");
const t = useScopedI18n("widget.timer");
const { click: playResetSound, toggle: playRunningSound } = useTiks({
theme: "soft",
volume: 0.12,
respectReducedMotion: true,
});
const { notify: playCompletionSound } = useTiks({
theme: "glass",
volume: 0.12,
respectReducedMotion: true,
});
const durations = useMemo<TimerDurations>(
() => ({
timerMinutes: options.timerMinutes,
focusMinutes: options.focusMinutes,
shortBreakMinutes: options.shortBreakMinutes,
longBreakMinutes: options.longBreakMinutes,
sessionsBeforeLongBreak: options.sessionsBeforeLongBreak,
autoStartBreaks: options.autoStartBreaks,
autoStartFocus: options.autoStartFocus,
}),
[
options.autoStartBreaks,
options.autoStartFocus,
options.focusMinutes,
options.longBreakMinutes,
options.sessionsBeforeLongBreak,
options.shortBreakMinutes,
options.timerMinutes,
],
);
const storagePrefix = boardId && itemId ? `homarr:timer:${boardId}:${itemId}` : undefined;
const [runtime, setRuntime] = useLocalWidgetState<TimerRuntimeState>({
key: storagePrefix ? `${storagePrefix}:state` : undefined,
version: 3,
defaultValue: () => createTimerRuntime(options.mode, durations),
validate: isTimerRuntimeState,
migrate: migrateTimerRuntimeState,
});
const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission());
const historyInitializedRef = useRef(false);
const handledHistoryIdRef = useRef<string | null>(null);
const completionSoundTimeoutRef = useRef<number | null>(null);
const stopCompletionSoundSequence = useCallback(() => {
if (completionSoundTimeoutRef.current === null) return;
window.clearTimeout(completionSoundTimeoutRef.current);
completionSoundTimeoutRef.current = null;
}, []);
useEffect(() => {
setRuntime((current) => synchronizeTimerRuntime(current, options.mode, durations));
}, [durations, options.mode, setRuntime]);
useEffect(() => {
if (!now) return;
setRuntime((current) => reconcileTimerRuntime(current, now.getTime(), durations));
}, [durations, now, setRuntime]);
useEffect(() => {
const latest = runtime.history.at(-1);
if (!historyInitializedRef.current) {
historyInitializedRef.current = true;
handledHistoryIdRef.current = latest?.id ?? null;
return;
}
if (!latest || handledHistoryIdRef.current === latest.id) return;
handledHistoryIdRef.current = latest.id;
const alertKey = storagePrefix ? `${storagePrefix}:alerted-completions` : undefined;
if (alertKey && !claimBrowserAlertOccurrence(alertKey, latest.id)) return;
if (runtime.alerts.notifications) {
showBrowserNotification({
title: t("alert.title"),
body: t("alert.completed", { phase: t(`phase.${latest.phase}`) }),
tag: latest.id,
});
}
if (!runtime.alerts.sound) return;
stopCompletionSoundSequence();
playCompletionSound();
if (!runtime.awaitingManualStart) return;
let remainingSounds = completionSoundCount - 1;
const playNextSound = () => {
completionSoundTimeoutRef.current = window.setTimeout(() => {
completionSoundTimeoutRef.current = null;
playCompletionSound();
remainingSounds -= 1;
if (remainingSounds > 0) playNextSound();
}, completionSoundIntervalMs);
};
playNextSound();
}, [
playCompletionSound,
runtime.alerts.notifications,
runtime.alerts.sound,
runtime.awaitingManualStart,
runtime.history,
stopCompletionSoundSequence,
storagePrefix,
t,
]);
useEffect(() => {
if (runtime.awaitingManualStart && runtime.alerts.sound) return;
stopCompletionSoundSequence();
}, [runtime.alerts.sound, runtime.awaitingManualStart, stopCompletionSoundSequence]);
useEffect(() => stopCompletionSoundSequence, [stopCompletionSoundSequence]);
if (!now) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const nowMs = now.getTime();
const remainingMs = getRemainingMs(runtime, nowMs);
const phaseDurationMs = runtime.totalDurationMs;
const progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100));
const controlSize = getTimerControlSize(width, height, displayMode);
const toggleRunning = () => {
if (runtime.alerts.sound) playRunningSound(runtime.status !== "running");
if (runtime.status === "running") {
setRuntime((current) => pauseTimerRuntime(current, nowMs));
return;
}
setRuntime((current) => startTimerRuntime(current, nowMs, durations));
};
const reset = () => {
if (runtime.alerts.sound) playResetSound();
setRuntime((current) => resetTimerRuntime(current, durations));
};
const setNotifications = async (enabled: boolean) => {
if (!enabled) {
setRuntime((current) => setTimerAlertPreference(current, "notifications", false));
return;
}
const permission = await requestBrowserNotificationPermission();
setNotificationPermission(permission);
if (permission === "granted") {
setRuntime((current) => setTimerAlertPreference(current, "notifications", true));
}
};
const setSound = (enabled: boolean) => {
playRunningSound(enabled);
setRuntime((current) => setTimerAlertPreference(current, "sound", enabled));
};
const shared = {
disabled: isEditMode,
controlSize,
onReset: reset,
onToggleRunning: toggleRunning,
progress,
remainingMs,
runtime,
};
if (displayMode !== "advanced") return <CompactTimer {...shared} />;
return (
<AdvancedTimer
{...shared}
durations={durations}
notificationPermission={notificationPermission}
onNotificationsChange={setNotifications}
onSoundChange={setSound}
/>
);
}
type TimerControlSize = "sm" | "md" | "lg";
interface TimerDisplayProps {
controlSize: TimerControlSize;
disabled: boolean;
onReset: () => void;
onToggleRunning: () => void;
progress: number;
remainingMs: number;
runtime: TimerRuntimeState;
}
const CompactTimer = ({
controlSize,
disabled,
onReset,
onToggleRunning,
progress,
remainingMs,
runtime,
}: TimerDisplayProps) => {
const t = useScopedI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t);
return (
<Stack
className={classes.root}
data-needs-attention={runtime.awaitingManualStart}
h="100%"
w="100%"
align="center"
justify="center"
gap={5}
p="xs"
>
<Group gap="xs" wrap="nowrap">
<Badge className={classes.phaseBadge} variant="light" aria-live="polite">
{phaseLabel}
</Badge>
{runtime.mode === "pomodoro" && (
<Text className={classes.completedFocus} size="xs" c="dimmed">
{t("compact.completedFocus", { count: runtime.completedFocusSessions })}
</Text>
)}
</Group>
<Text className={classes.time} fz={36} fw={750} lh={1} component="time">
{formatTimerDuration(remainingMs)}
</Text>
<Progress w="100%" value={progress} aria-label={t("progress", { progress: Math.round(progress) })} />
<TimerControls
controlSize={controlSize}
disabled={disabled}
isRunning={runtime.status === "running"}
onReset={onReset}
onToggleRunning={onToggleRunning}
/>
</Stack>
);
};
interface AdvancedTimerProps extends TimerDisplayProps {
durations: TimerDurations;
notificationPermission: ReturnType<typeof getBrowserNotificationPermission>;
onNotificationsChange: (enabled: boolean) => Promise<void>;
onSoundChange: (enabled: boolean) => void;
}
const AdvancedTimer = ({
controlSize,
disabled,
durations,
notificationPermission,
onNotificationsChange,
onReset,
onSoundChange,
onToggleRunning,
progress,
remainingMs,
runtime,
}: AdvancedTimerProps) => {
const t = useScopedI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t);
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Paper className={classes.hero} data-needs-attention={runtime.awaitingManualStart} withBorder p="lg">
<Stack align="center" gap="sm">
<Badge variant="light" size="lg" aria-live="polite">
{phaseLabel}
</Badge>
<Text className={classes.time} fz={64} fw={750} lh={1} component="time">
{formatTimerDuration(remainingMs)}
</Text>
<Progress w="100%" value={progress} aria-label={t("progress", { progress: Math.round(progress) })} />
<TimerControls
controlSize={controlSize}
disabled={disabled}
isRunning={runtime.status === "running"}
onReset={onReset}
onToggleRunning={onToggleRunning}
/>
</Stack>
</Paper>
{runtime.mode === "pomodoro" && <PomodoroSequence runtime={runtime} durations={durations} />}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper withBorder p="md">
<Group gap="xs" mb="sm">
<IconSettings size={18} aria-hidden />
<Text fw={600}>{t("advanced.settings")}</Text>
</Group>
<SettingsSummary mode={runtime.mode} durations={durations} />
<Text size="xs" c="dimmed" mt="sm">
{t("advanced.settingsHint")}
</Text>
</Paper>
<Paper withBorder p="md">
<Group gap="xs" mb="sm">
<IconBell size={18} aria-hidden />
<Text fw={600}>{t("advanced.localAlerts")}</Text>
</Group>
<Stack gap="sm">
<Switch
checked={runtime.alerts.notifications && notificationPermission === "granted"}
disabled={disabled || notificationPermission === "unsupported" || notificationPermission === "denied"}
label={t("advanced.notifications")}
onChange={(event) => void onNotificationsChange(event.currentTarget.checked)}
/>
<Switch
checked={runtime.alerts.sound}
disabled={disabled}
label={t("advanced.sound")}
onChange={(event) => onSoundChange(event.currentTarget.checked)}
thumbIcon={<IconVolume size={12} />}
/>
<Text size="xs" c="dimmed">
{t("advanced.localAlertsHint")}
</Text>
{notificationPermission === "denied" && <Alert color="yellow">{t("advanced.notificationsDenied")}</Alert>}
</Stack>
</Paper>
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const TimerControls = ({
controlSize,
disabled,
isRunning,
onReset,
onToggleRunning,
}: {
controlSize: TimerControlSize;
disabled: boolean;
isRunning: boolean;
onReset: () => void;
onToggleRunning: () => void;
}) => {
const t = useScopedI18n("widget.timer");
const toggleLabel = isRunning ? t("action.pause") : t("action.start");
return (
<Group gap="xs" wrap="nowrap">
<Tooltip label={toggleLabel}>
<ActionIcon
size={controlSize}
variant="filled"
disabled={disabled}
onClick={onToggleRunning}
aria-label={toggleLabel}
>
{isRunning ? <IconPlayerPause /> : <IconPlayerPlay />}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.reset")}>
<ActionIcon
size={controlSize}
variant="light"
disabled={disabled}
onClick={onReset}
aria-label={t("action.reset")}
>
<IconRefresh />
</ActionIcon>
</Tooltip>
</Group>
);
};
type ScopedTimerTranslator = ReturnType<typeof useScopedI18n<"widget.timer">>;
const getTimerPhaseLabel = (runtime: TimerRuntimeState, t: ScopedTimerTranslator) => {
const phase = t(`phase.${runtime.phase}`);
if (!runtime.awaitingManualStart) return phase;
if (runtime.mode === "timer") return t("attention.timerComplete");
return t("attention.phaseReady", { phase });
};
const getTimerControlSize = (
width: number,
height: number,
displayMode: "compact" | "advanced" | undefined,
): TimerControlSize => {
if (displayMode === "advanced") return "lg";
if (width < 150 || height < 115) return "sm";
if (width < 220 || height < 150) return "md";
return "lg";
};
const PomodoroSequence = ({ runtime, durations }: { runtime: TimerRuntimeState; durations: TimerDurations }) => {
const t = useScopedI18n("widget.timer");
return (
<Paper withBorder p="md">
<Text fw={600} mb="xs">
{t("advanced.sequence")}
</Text>
<Group gap="xs" wrap="wrap">
{Array.from({ length: durations.sessionsBeforeLongBreak }, (_, index) => (
<Group key={index} gap={4} wrap="nowrap">
<Badge variant={index < runtime.completedFocusSessions ? "filled" : "light"}>
{t("advanced.focusSession", { session: index + 1 })}
</Badge>
<Text size="xs" c="dimmed">
{t("phase.shortBreak")}
</Text>
</Group>
))}
<Badge color="violet" variant={runtime.phase === "longBreak" ? "filled" : "light"}>
{t("phase.longBreak")}
</Badge>
</Group>
</Paper>
);
};
const SettingsSummary = ({ mode, durations }: { mode: TimerRuntimeState["mode"]; durations: TimerDurations }) => {
const t = useScopedI18n("widget.timer");
if (mode === "timer") return <SettingLine label={t("option.timerMinutes.label")} value={durations.timerMinutes} />;
return (
<Stack gap={5}>
<SettingLine label={t("option.focusMinutes.label")} value={durations.focusMinutes} />
<SettingLine label={t("option.shortBreakMinutes.label")} value={durations.shortBreakMinutes} />
<SettingLine label={t("option.longBreakMinutes.label")} value={durations.longBreakMinutes} />
<SettingLine label={t("option.sessionsBeforeLongBreak.label")} value={durations.sessionsBeforeLongBreak} />
</Stack>
);
};
const SettingLine = ({ label, value }: { label: string; value: number }) => (
<Group justify="space-between" gap="md" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Group>
);
export const formatTimerDuration = (durationMs: number): string => {
const totalSeconds = Math.max(0, Math.ceil(durationMs / 1_000));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const minuteText = String(minutes).padStart(2, "0");
const secondText = String(seconds).padStart(2, "0");
if (hours === 0) return `${minuteText}:${secondText}`;
return `${String(hours).padStart(2, "0")}:${minuteText}:${secondText}`;
};
+54
View File
@@ -0,0 +1,54 @@
import { IconClockPlay } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("timer", {
icon: IconClockPlay,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
mode: factory.select({
defaultValue: "pomodoro",
options: [
{ value: "timer", label: (t) => t("widget.timer.option.mode.options.timer") },
{ value: "pomodoro", label: (t) => t("widget.timer.option.mode.options.pomodoro") },
],
}),
timerMinutes: factory.number({
defaultValue: 10,
validate: z.number().int().min(1).max(1440),
}),
focusMinutes: factory.number({
defaultValue: 25,
validate: z.number().int().min(1).max(180),
}),
shortBreakMinutes: factory.number({
defaultValue: 5,
validate: z.number().int().min(1).max(60),
}),
longBreakMinutes: factory.number({
defaultValue: 15,
validate: z.number().int().min(1).max(180),
}),
sessionsBeforeLongBreak: factory.number({
defaultValue: 4,
validate: z.number().int().min(1).max(12),
}),
autoStartBreaks: factory.switch({ defaultValue: false }),
autoStartFocus: factory.switch({ defaultValue: false }),
}),
{
timerMinutes: { shouldHide: ({ mode }) => mode !== "timer" },
focusMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
shortBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
longBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
sessionsBeforeLongBreak: { shouldHide: ({ mode }) => mode !== "pomodoro" },
autoStartBreaks: { shouldHide: ({ mode }) => mode !== "pomodoro" },
autoStartFocus: { shouldHide: ({ mode }) => mode !== "pomodoro" },
},
);
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
createTimerRuntime,
getRemainingMs,
isTimerRuntimeState,
pauseTimerRuntime,
reconcileTimerRuntime,
startTimerRuntime,
synchronizeTimerRuntime,
} from "./runtime";
const durations = {
timerMinutes: 10,
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLongBreak: 2,
autoStartBreaks: false,
autoStartFocus: false,
};
describe("timer runtime", () => {
it("uses a persisted deadline and preserves a paused remainder", () => {
const initial = createTimerRuntime("timer", durations);
const running = startTimerRuntime(initial, 1_000, durations);
expect(running.deadline).toBe(601_000);
expect(getRemainingMs(running, 61_000)).toBe(540_000);
const paused = pauseTimerRuntime(running, 61_000);
expect(paused).toMatchObject({ status: "paused", remainingMs: 540_000, deadline: null });
expect(startTimerRuntime(paused, 100_000, durations).deadline).toBe(640_000);
});
it("moves through short and long breaks using completed focus sessions", () => {
const firstFocus = startTimerRuntime(createTimerRuntime("pomodoro", durations), 0, durations);
const shortBreak = reconcileTimerRuntime(firstFocus, 25 * 60_000, durations);
expect(shortBreak).toMatchObject({ phase: "shortBreak", status: "idle", completedFocusSessions: 1 });
const secondFocus = startTimerRuntime(
{ ...shortBreak, phase: "focus", remainingMs: 25 * 60_000 },
2_000_000,
durations,
);
const longBreak = reconcileTimerRuntime(secondFocus, 3_500_000, durations);
expect(longBreak).toMatchObject({ phase: "longBreak", status: "idle", completedFocusSessions: 2 });
});
it("catches up chained automatic phases from their original deadlines", () => {
const automatic = {
...durations,
focusMinutes: 1,
shortBreakMinutes: 1,
autoStartBreaks: true,
autoStartFocus: true,
};
const running = startTimerRuntime(createTimerRuntime("pomodoro", automatic), 0, automatic);
const reconciled = reconcileTimerRuntime(running, 125_000, automatic);
expect(reconciled).toMatchObject({ phase: "focus", status: "running", completedFocusSessions: 1 });
expect(reconciled.deadline).toBe(180_000);
expect(reconciled.history).toHaveLength(2);
});
it("keeps only the latest 20 completions", () => {
let state = startTimerRuntime(createTimerRuntime("timer", { ...durations, timerMinutes: 1 }), 0, durations);
for (let index = 1; index <= 25; index += 1) {
state = reconcileTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 });
state = startTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 });
}
expect(state.history).toHaveLength(20);
});
it("resets only live state when the permanent mode changes", () => {
const initial = createTimerRuntime("timer", durations);
const withHistory = {
...initial,
history: [{ id: "timer:1", phase: "timer" as const, completedAt: 1, durationMs: 1 }],
};
const synchronized = synchronizeTimerRuntime(withHistory, "pomodoro", durations);
expect(synchronized).toMatchObject({ mode: "pomodoro", phase: "focus", status: "idle" });
expect(synchronized.history).toEqual(withHistory.history);
});
it("rejects inconsistent stored deadlines and modes", () => {
const valid = createTimerRuntime("pomodoro", durations);
expect(isTimerRuntimeState(valid)).toBe(true);
expect(isTimerRuntimeState({ ...valid, status: "running", deadline: null })).toBe(false);
expect(isTimerRuntimeState({ ...valid, phase: "timer" })).toBe(false);
});
});
+287
View File
@@ -0,0 +1,287 @@
export type TimerMode = "timer" | "pomodoro";
export type TimerPhase = "timer" | "focus" | "shortBreak" | "longBreak";
export type TimerStatus = "idle" | "running" | "paused";
export interface TimerDurations {
timerMinutes: number;
focusMinutes: number;
shortBreakMinutes: number;
longBreakMinutes: number;
sessionsBeforeLongBreak: number;
autoStartBreaks: boolean;
autoStartFocus: boolean;
}
export interface TimerHistoryEntry {
id: string;
phase: TimerPhase;
completedAt: number;
durationMs: number;
}
export interface TimerAlertPreferences {
notifications: boolean;
sound: boolean;
}
export interface TimerRuntimeState {
mode: TimerMode;
phase: TimerPhase;
status: TimerStatus;
remainingMs: number;
totalDurationMs: number;
deadline: number | null;
completedFocusSessions: number;
history: TimerHistoryEntry[];
alerts: TimerAlertPreferences;
awaitingManualStart: boolean;
}
const maximumHistoryEntries = 20;
const maximumReconciledPhases = 100;
const maximumDurationMs = 24 * 60 * 60_000;
export const createTimerRuntime = (mode: TimerMode, durations: TimerDurations): TimerRuntimeState => {
const phase = getInitialPhase(mode);
return {
mode,
phase,
status: "idle",
remainingMs: getPhaseDurationMs(phase, durations),
totalDurationMs: getPhaseDurationMs(phase, durations),
deadline: null,
completedFocusSessions: 0,
history: [],
alerts: { notifications: false, sound: true },
awaitingManualStart: false,
};
};
export const isTimerRuntimeState = (value: unknown): value is TimerRuntimeState => {
if (!isRecord(value)) return false;
if (value.mode !== "timer" && value.mode !== "pomodoro") return false;
if (!isTimerPhase(value.phase) || !isTimerStatus(value.status)) return false;
if (value.mode === "timer" && value.phase !== "timer") return false;
if (value.mode === "pomodoro" && value.phase === "timer") return false;
if (!isFiniteNumber(value.remainingMs) || value.remainingMs < 0) return false;
if (!isFiniteNumber(value.totalDurationMs) || value.totalDurationMs <= 0) return false;
if (value.totalDurationMs > maximumDurationMs || value.remainingMs > value.totalDurationMs) return false;
if (value.deadline !== null && !isFiniteNumber(value.deadline)) return false;
if (value.status === "running" && value.deadline === null) return false;
if (value.status !== "running" && value.deadline !== null) return false;
if (!isFiniteNumber(value.completedFocusSessions) || !Number.isInteger(value.completedFocusSessions)) return false;
if (value.completedFocusSessions < 0 || value.completedFocusSessions > 12) return false;
if (!Array.isArray(value.history) || value.history.length > maximumHistoryEntries) return false;
if (!value.history.every(isTimerHistoryEntry)) return false;
if (!isRecord(value.alerts)) return false;
if (typeof value.alerts.notifications !== "boolean" || typeof value.alerts.sound !== "boolean") return false;
return typeof value.awaitingManualStart === "boolean";
};
export const migrateTimerRuntimeState = (value: unknown, storedVersion: number): TimerRuntimeState | undefined => {
if ((storedVersion !== 1 && storedVersion !== 2) || !isRecord(value) || !isRecord(value.alerts)) return undefined;
let awaitingManualStart = false;
if (storedVersion === 2 && typeof value.awaitingManualStart === "boolean") {
awaitingManualStart = value.awaitingManualStart;
}
const migrated = {
...value,
alerts: { ...value.alerts, sound: true },
awaitingManualStart,
};
if (!isTimerRuntimeState(migrated)) return undefined;
return migrated;
};
export const getPhaseDurationMs = (phase: TimerPhase, durations: TimerDurations): number => {
if (phase === "timer") return durations.timerMinutes * 60_000;
if (phase === "focus") return durations.focusMinutes * 60_000;
if (phase === "shortBreak") return durations.shortBreakMinutes * 60_000;
return durations.longBreakMinutes * 60_000;
};
export const getRemainingMs = (state: TimerRuntimeState, now: number): number => {
if (state.status !== "running" || state.deadline === null) return state.remainingMs;
return Math.max(0, state.deadline - now);
};
export const startTimerRuntime = (
state: TimerRuntimeState,
now: number,
durations: TimerDurations,
): TimerRuntimeState => {
if (state.status === "running") return state;
const remainingMs = state.remainingMs > 0 ? state.remainingMs : getPhaseDurationMs(state.phase, durations);
const totalDurationMs = state.remainingMs > 0 ? state.totalDurationMs : remainingMs;
return {
...state,
status: "running",
remainingMs,
totalDurationMs,
deadline: now + remainingMs,
awaitingManualStart: false,
};
};
export const pauseTimerRuntime = (state: TimerRuntimeState, now: number): TimerRuntimeState => {
if (state.status !== "running") return state;
return { ...state, status: "paused", remainingMs: getRemainingMs(state, now), deadline: null };
};
export const resetTimerRuntime = (state: TimerRuntimeState, durations: TimerDurations): TimerRuntimeState => {
const phase = getInitialPhase(state.mode);
return {
...state,
phase,
status: "idle",
remainingMs: getPhaseDurationMs(phase, durations),
totalDurationMs: getPhaseDurationMs(phase, durations),
deadline: null,
completedFocusSessions: 0,
awaitingManualStart: false,
};
};
export const synchronizeTimerRuntime = (
state: TimerRuntimeState,
mode: TimerMode,
durations: TimerDurations,
): TimerRuntimeState => {
if (state.mode !== mode) {
const initial = createTimerRuntime(mode, durations);
return { ...initial, history: state.history, alerts: state.alerts };
}
if (state.status !== "idle") return state;
const remainingMs = getPhaseDurationMs(state.phase, durations);
const completedFocusSessions = Math.min(state.completedFocusSessions, durations.sessionsBeforeLongBreak - 1);
if (
remainingMs === state.remainingMs &&
remainingMs === state.totalDurationMs &&
completedFocusSessions === state.completedFocusSessions
) {
return state;
}
return { ...state, remainingMs, totalDurationMs: remainingMs, completedFocusSessions };
};
export const reconcileTimerRuntime = (
state: TimerRuntimeState,
now: number,
durations: TimerDurations,
): TimerRuntimeState => {
let next = state;
let reconciled = 0;
while (next.status === "running" && next.deadline !== null && next.deadline <= now) {
next = completeTimerPhase(next, next.deadline, durations);
reconciled += 1;
if (reconciled >= maximumReconciledPhases) break;
}
if (reconciled < maximumReconciledPhases) return next;
if (next.status !== "running") return next;
const remainingMs = getPhaseDurationMs(next.phase, durations);
return { ...next, remainingMs, deadline: now + remainingMs };
};
export const setTimerAlertPreference = (
state: TimerRuntimeState,
preference: keyof TimerAlertPreferences,
enabled: boolean,
): TimerRuntimeState => ({
...state,
alerts: { ...state.alerts, [preference]: enabled },
});
const completeTimerPhase = (
state: TimerRuntimeState,
completedAt: number,
durations: TimerDurations,
): TimerRuntimeState => {
const durationMs = state.totalDurationMs;
const history = [
...state.history,
{ id: `${state.phase}:${completedAt}`, phase: state.phase, completedAt, durationMs },
].slice(-maximumHistoryEntries);
if (state.mode === "timer") {
return {
...state,
status: "idle",
remainingMs: getPhaseDurationMs(state.phase, durations),
totalDurationMs: getPhaseDurationMs(state.phase, durations),
deadline: null,
history,
awaitingManualStart: true,
};
}
if (state.phase === "focus") {
const completedFocusSessions = state.completedFocusSessions + 1;
const isLongBreak = completedFocusSessions >= durations.sessionsBeforeLongBreak;
const phase: TimerPhase = isLongBreak ? "longBreak" : "shortBreak";
return startNextPhase(
{ ...state, phase, completedFocusSessions, history },
completedAt,
durations,
durations.autoStartBreaks,
);
}
const completedFocusSessions = state.phase === "longBreak" ? 0 : state.completedFocusSessions;
return startNextPhase(
{ ...state, phase: "focus", completedFocusSessions, history },
completedAt,
durations,
durations.autoStartFocus,
);
};
const startNextPhase = (
state: TimerRuntimeState,
completedAt: number,
durations: TimerDurations,
autoStart: boolean,
): TimerRuntimeState => {
const remainingMs = getPhaseDurationMs(state.phase, durations);
if (!autoStart) {
return {
...state,
status: "idle",
remainingMs,
totalDurationMs: remainingMs,
deadline: null,
awaitingManualStart: true,
};
}
return {
...state,
status: "running",
remainingMs,
totalDurationMs: remainingMs,
deadline: completedAt + remainingMs,
awaitingManualStart: false,
};
};
const getInitialPhase = (mode: TimerMode): TimerPhase => {
if (mode === "timer") return "timer";
return "focus";
};
const isTimerPhase = (value: unknown): value is TimerPhase =>
value === "timer" || value === "focus" || value === "shortBreak" || value === "longBreak";
const isTimerStatus = (value: unknown): value is TimerStatus =>
value === "idle" || value === "running" || value === "paused";
const isTimerHistoryEntry = (value: unknown): value is TimerHistoryEntry => {
if (!isRecord(value) || typeof value.id !== "string" || !isTimerPhase(value.phase)) return false;
return isFiniteNumber(value.completedAt) && isFiniteNumber(value.durationMs) && value.durationMs > 0;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
+220
View File
@@ -0,0 +1,220 @@
"use client";
import {
Badge,
Box,
Group,
Paper,
Progress,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../common/locale";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import { getTodayLayout, getTodayStats } from "./date-stats";
const formatProgress = (value: number) => Math.round(value);
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
const now = useWidgetNow("minute");
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.today");
if (now === null) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const stats = getTodayStats(now, options.weekConvention, locale);
if (displayMode === "advanced") {
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
}
const layout = getTodayLayout(width, height);
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
const month = formatLocalizedDate(now, locale, { month: "long" });
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
if (layout.tiny) {
return (
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday} · {month}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
{now.getDate()}
</Text>
</Stack>
);
}
return (
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
{weekday}
</Text>
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
</Text>
</Stack>
{options.showYearProgress && (
<Box>
<RingProgress
aria-hidden
size={54}
thickness={5}
sections={[{ value: stats.yearProgress, color: "blue" }]}
label={
<Text size="xs" ta="center" fw={700}>
{formatProgress(stats.yearProgress)}%
</Text>
}
/>
<VisuallyHidden>
<progress value={stats.yearProgress} max={100}>
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
</progress>
</VisuallyHidden>
</Box>
)}
</Group>
{layout.showStats && (
<Group gap={6} wrap="wrap">
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
{options.showDayOfYear && (
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
)}
{layout.showQuarter && options.showQuarter && (
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
)}
{layout.showQuarter && (
<Text size="xs" c="dimmed">
{t("daysAfterToday", { days: stats.daysAfterToday })}
</Text>
)}
</Group>
)}
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
</Stack>
);
}
interface AdvancedTodayProps {
now: Date;
locale: string;
options: WidgetComponentProps<"today">["options"];
stats: ReturnType<typeof getTodayStats>;
}
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
const t = useScopedI18n("widget.today");
const numberFormatter = new Intl.NumberFormat(locale);
const visibleMetrics = [
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
options.showDayOfYear && {
label: t("advanced.dayOfYear"),
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
},
options.showQuarter && { label: t("advanced.quarter"), value: `Q${stats.quarter}` },
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Stack gap={0}>
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
</Text>
{options.showWeekNumber && (
<Text size="sm" c="dimmed">
{t(`weekConvention.${options.weekConvention}`)}
</Text>
)}
</Stack>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{visibleMetrics.map((metric) => (
<Paper key={metric.label} withBorder p="sm">
<Text size="xs" c="dimmed">
{metric.label}
</Text>
<Text fz="xl" fw={700}>
{metric.value}
</Text>
</Paper>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
</SimpleGrid>
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
{Array.from({ length: 12 }, (_, monthIndex) => {
const date = new Date(now.getFullYear(), monthIndex, 1);
const isCurrent = monthIndex === stats.monthIndex;
return (
<Paper
key={monthIndex}
withBorder
p="xs"
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
>
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
{formatLocalizedDate(date, locale, { month: "short" })}
</Text>
{isCurrent && (
<Text size="xs" c="dimmed" ta="center">
{now.getDate()}
</Text>
)}
</Paper>
);
})}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
const roundedValue = formatProgress(value);
return (
<Box>
<Group justify="space-between" gap="xs" mb={3}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600}>
{roundedValue}%
</Text>
</Group>
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
</Box>
);
};
const toDateTime = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getTodayLayout, getTodayStats } from "./date-stats";
describe("today date statistics", () => {
it("accounts for leap years", () => {
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
expect(stats.dayOfYear).toBe(366);
expect(stats.daysInYear).toBe(366);
expect(stats.daysAfterToday).toBe(0);
});
it("uses the requested week convention", () => {
const date = new Date(2023, 0, 1, 12);
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
});
it("returns bounded calendar progress", () => {
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
expect(stats.quarter).toBe(3);
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
expect(stats.weekProgress).toBeLessThanOrEqual(100);
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
expect(stats.monthProgress).toBeLessThanOrEqual(100);
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
expect(stats.yearProgress).toBeLessThanOrEqual(100);
});
});
describe("today compact layout", () => {
it("keeps tiny widgets focused on the date", () => {
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
});
it("reveals secondary information only when both axes have room", () => {
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
expect(getTodayLayout(300, 180)).toEqual({
tiny: false,
showFullDate: true,
showStats: true,
showQuarter: true,
showMonthProgress: true,
});
});
});
+172
View File
@@ -0,0 +1,172 @@
import dayjs from "dayjs";
import dayOfYear from "dayjs/plugin/dayOfYear";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
dayjs.extend(dayOfYear);
dayjs.extend(quarterOfYear);
export type WeekConvention = "locale" | "iso";
export interface TodayStats {
dayOfYear: number;
daysInYear: number;
daysAfterToday: number;
monthIndex: number;
quarter: number;
weekNumber: number;
weekProgress: number;
monthProgress: number;
yearProgress: number;
}
const getProgress = (value: number, start: number, end: number) => {
if (end <= start) return 0;
return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
};
export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
const value = dayjs(date);
const yearStart = value.startOf("year");
const nextYearStart = yearStart.add(1, "year");
const monthStart = value.startOf("month");
const nextMonthStart = monthStart.add(1, "month");
const weekRules = getWeekRules(weekConvention, locale);
const weekDay = value.day() === 0 ? 7 : value.day();
const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
const currentDayOfYear = value.dayOfYear();
const daysInYear = nextYearStart.diff(yearStart, "day");
return {
dayOfYear: currentDayOfYear,
daysInYear,
daysAfterToday: daysInYear - currentDayOfYear,
monthIndex: value.month(),
quarter: value.quarter(),
weekNumber: getWeekNumber(date, weekRules),
weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
};
};
interface WeekRules {
firstDay: number;
minimalDays: number;
}
interface LocaleWeekInfo {
firstDay: number;
minimalDays?: number;
}
type LocaleWithWeekInfo = Intl.Locale & {
weekInfo?: LocaleWeekInfo;
getWeekInfo?: () => LocaleWeekInfo;
};
const fourDayMinimumRegions = new Set([
"AD",
"AN",
"AT",
"AX",
"BE",
"BG",
"CH",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FJ",
"FO",
"FR",
"GF",
"GB",
"GG",
"GI",
"GP",
"GR",
"IE",
"IM",
"IS",
"IT",
"LI",
"LT",
"LU",
"MC",
"MQ",
"NL",
"NO",
"PL",
"PT",
"RE",
"RU",
"SE",
"SJ",
"SK",
"VA",
]);
const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
try {
const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
if (weekInfo) {
const region = localeInfo.maximize().region;
const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
}
} catch {
// Invalid locales fall back to the common US week convention.
}
return { firstDay: 7, minimalDays: 1 };
};
const getWeekNumber = (date: Date, rules: WeekRules): number => {
const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
let weekYear = date.getFullYear();
let firstWeekStart = getFirstWeekStart(weekYear, rules);
if (dateDay < firstWeekStart) {
weekYear -= 1;
firstWeekStart = getFirstWeekStart(weekYear, rules);
} else {
const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
if (dateDay >= nextFirstWeekStart) return 1;
}
return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
};
const getFirstWeekStart = (year: number, rules: WeekRules): number => {
const januaryFirst = Date.UTC(year, 0, 1);
const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
const daysInFirstWeek = 7 - daysBeforeJanuary;
if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
return januaryFirst + daysInFirstWeek * 86_400_000;
};
export interface TodayLayout {
tiny: boolean;
showFullDate: boolean;
showStats: boolean;
showQuarter: boolean;
showMonthProgress: boolean;
}
export const getTodayLayout = (width: number, height: number): TodayLayout => {
const tiny = width < 150 || height < 96;
return {
tiny,
showFullDate: !tiny && width >= 180,
showStats: !tiny && width >= 210 && height >= 115,
showQuarter: width >= 240 && height >= 145,
showMonthProgress: width >= 300 && height >= 170,
};
};
+24
View File
@@ -0,0 +1,24 @@
import { IconCalendarWeek } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("today", {
icon: IconCalendarWeek,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from((factory) => ({
weekConvention: factory.select({
defaultValue: "locale",
options: [
{ value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
{ value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
],
}),
showWeekNumber: factory.switch({ defaultValue: true }),
showDayOfYear: factory.switch({ defaultValue: true }),
showQuarter: factory.switch({ defaultValue: true }),
showYearProgress: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
+439
View File
@@ -0,0 +1,439 @@
import type { ReactNode } from "react";
import { useId } from "react";
import { CompositeChart } from "@mantine/charts";
import {
Box,
Group,
Paper,
Popover,
ScrollArea,
SimpleGrid,
Stack,
Text,
UnstyledButton,
VisuallyHidden,
} from "@mantine/core";
import { IconDroplets, IconMapPin, IconSunHigh, IconSunrise, IconSunset, IconWind } from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import classes from "./component.module.css";
import { DailyWeatherDetails } from "./details";
import type { HourlyChartRow, WeatherData } from "./format";
import {
getHourlyChartData,
getPreferredDate,
getPreferredTime,
getPreferredUnit,
getPreferredWindSpeed,
} from "./format";
import { getWeatherKind, WeatherDescription } from "./icon";
import { getAdvancedWeatherLayout } from "./layout";
interface AdvancedWeatherProps {
height: number;
options: WidgetProps<"weather">["options"];
weather: WeatherData;
width: number;
}
interface MetricCardProps {
icon: ReactNode;
label: string;
value: ReactNode;
}
interface WeatherChartDatum {
detailLabel: string;
observedAt: string;
precipitationProbability: number;
precipitationSummary: string;
temperature: number;
temperatureSummary: string;
tooltipLabel: string;
}
interface HourlyChartTooltipProps {
active?: boolean;
payload?: Array<{ payload?: WeatherChartDatum }>;
}
const HourlyChartTooltip = ({ active, payload }: HourlyChartTooltipProps) => {
const datum = payload?.[0]?.payload;
if (!active || !datum) return null;
return (
<Paper p="xs" radius="sm" shadow="md" withBorder>
<Text size="sm" fw={600} mb={4}>
{datum.tooltipLabel}
</Text>
<Text size="xs">
{datum.temperatureSummary} · {datum.precipitationSummary}
</Text>
<Text size="xs">{datum.detailLabel}</Text>
</Paper>
);
};
const MetricCard = ({ icon, label, value }: MetricCardProps) => (
<Paper className={classes.metricCard} p="sm" radius="md">
<Group gap="xs" wrap="nowrap" align="flex-start">
{icon}
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} truncate>
{value}
</Text>
</Stack>
</Group>
</Paper>
);
export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWeatherProps) => {
const headingId = useId();
const chartId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
const layout = getAdvancedWeatherLayout(width, height);
const today = weather.daily[0];
const nextHour = weather.hourly[0];
const hourly = getHourlyChartData(weather, options.isFormatFahrenheit);
const speedUnit = options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour");
const temperatureUnit = options.isFormatFahrenheit ? "°F" : "°C";
const chartData = hourly.map<WeatherChartDatum>((hour) => {
const condition = hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`);
const temperature = hour.temperature === null ? "?" : hour.temperature.toFixed(1);
const precipitationProbability = hour.precipitationProbability ?? "?";
let detailLabel = t("advanced.hourlyTooltipWithoutHumidity", {
amount: hour.precipitation?.toFixed(1) ?? "?",
feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?",
speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed),
speedUnit,
temperatureUnit,
});
if (options.showHumidity) {
detailLabel = t("advanced.hourlyTooltip", {
amount: hour.precipitation?.toFixed(1) ?? "?",
feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?",
humidity: hour.relativeHumidity ?? "?",
speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed),
speedUnit,
temperatureUnit,
});
}
return {
detailLabel,
observedAt: hour.observedAt,
precipitationProbability: hour.precipitationProbability ?? Number.NaN,
precipitationSummary: `${t("advanced.precipitationChance")}: ${precipitationProbability}%`,
temperature: hour.temperature ?? Number.NaN,
temperatureSummary: `${t("advanced.temperature")}: ${temperature}${temperatureUnit}`,
tooltipLabel: `${getPreferredTime(hour.observedAt, locale, weather.timezone)} · ${condition}`,
};
});
const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt);
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack className={classes.advancedRoot} gap="md" p="md">
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Group gap="md" wrap="nowrap">
<AnimatedWeatherIcon
animated={options.animateIcons}
code={weather.current.weatherCode}
isDay={weather.current.isDay}
size={56}
/>
<Stack gap={2}>
<Text fz={32} fw={700} lh={1}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />
<Text size="sm" c="dimmed">
{t("advanced.feelsLike", {
temperature: getPreferredUnit(
weather.current.apparentTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
})}
</Text>
</Stack>
</Group>
<Stack gap={2} align="flex-end" className={classes.locationSummary}>
<Group gap={5} wrap="nowrap" maw="100%">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm" fw={600} truncate>
{options.location.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("advanced.observedAt", {
time: getPreferredTime(weather.current.observedAt, locale, weather.timezone),
})}
</Text>
{today && (
<Text size="sm">
{t("advanced.highLow", {
maximum: getPreferredUnit(
today.maxTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
minimum: getPreferredUnit(
today.minTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
),
})}
</Text>
)}
</Stack>
</Group>
<SimpleGrid cols={layout.metricColumns} spacing="xs">
<MetricCard
icon={<IconDroplets size={18} aria-hidden />}
label={t("advanced.nextHourPrecipitation")}
value={t("advanced.precipitationValue", {
amount: nextHour?.precipitation?.toFixed(1) ?? "?",
probability: nextHour?.precipitationProbability ?? "?",
})}
/>
{options.showHumidity && (
<MetricCard
icon={<IconDroplets size={18} aria-hidden />}
label={t("advanced.currentHumidity")}
value={`${weather.current.relativeHumidity}%`}
/>
)}
<MetricCard
icon={<IconWind size={18} aria-hidden />}
label={t("advanced.currentWind")}
value={t("advanced.windValue", {
gusts: getPreferredWindSpeed(weather.current.windGusts, options.useImperialSpeed),
speed: getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed),
unit: speedUnit,
})}
/>
<MetricCard
icon={<IconSunHigh size={18} aria-hidden />}
label={t("advanced.todayUv")}
value={today?.uvIndex?.toFixed(1) ?? "?"}
/>
<MetricCard
icon={<IconSunrise size={18} aria-hidden />}
label={t("dailyForecast.sunrise")}
value={getPreferredTime(today?.sunriseAt, locale, weather.timezone)}
/>
<MetricCard
icon={<IconSunset size={18} aria-hidden />}
label={t("dailyForecast.sunset")}
value={getPreferredTime(today?.sunsetAt, locale, weather.timezone)}
/>
</SimpleGrid>
{chartData.length > 0 && (
<Box component="figure" m={0} aria-labelledby={chartId}>
<Text id={chartId} component="figcaption" size="sm" fw={600} mb="xs">
{t("advanced.hourlyForecast")}
</Text>
<Box aria-hidden>
<CompositeChart
h={layout.chartHeight}
data={chartData}
dataKey="observedAt"
series={[
{ name: "temperature", label: t("advanced.temperature"), color: "orange.6", type: "area" },
{
name: "precipitationProbability",
label: t("advanced.precipitationChance"),
color: "blue.5",
type: "bar",
yAxisId: "right",
},
]}
curveType="monotone"
gridAxis="y"
tickLine="none"
withDots={false}
withLegend
withRightYAxis={layout.showChartAxes}
withYAxis={layout.showChartAxes}
yAxisProps={{ width: 38, tickFormatter: (value: number) => `${value}°` }}
rightYAxisProps={{ width: 38, domain: [0, 100], tickFormatter: (value: number) => `${value}%` }}
xAxisProps={{
ticks,
interval: 0,
tickFormatter: (value: string) => getPreferredTime(value, locale, weather.timezone),
}}
tooltipAnimationDuration={0}
tooltipProps={{ content: <HourlyChartTooltip /> }}
/>
</Box>
<VisuallyHidden>
<HourlyWeatherTable
hourly={hourly}
locale={locale}
speedUnit={speedUnit}
temperatureUnit={temperatureUnit}
timeZone={weather.timezone}
useImperialSpeed={options.useImperialSpeed}
showHumidity={options.showHumidity}
/>
</VisuallyHidden>
</Box>
)}
<Text id={headingId} component="h3" size="sm" fw={600} m={0}>
{t("advanced.forecast")}
</Text>
<SimpleGrid aria-labelledby={headingId} cols={layout.dailyColumns} spacing="xs">
{weather.daily.map((day) => (
<Popover key={day.date} position="bottom" withArrow shadow="md" offset={10}>
<Popover.Target>
<UnstyledButton
className={classes.dailyCardButton}
aria-label={t("detailsFor", {
date: getPreferredDate(day.date, locale, { weekday: "long", month: "short", day: "numeric" }),
})}
>
<Paper className={classes.dailyCard} p="sm" radius="md">
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={700} tt="capitalize">
{getPreferredDate(day.date, locale, { weekday: "short" })}
</Text>
<Text size="xs" c="dimmed">
{getPreferredDate(day.date, locale, { month: "short", day: "numeric" })}
</Text>
</Stack>
<AnimatedWeatherIcon animated={options.animateIcons} code={day.weatherCode} size={28} />
</Group>
<Text size="sm" fw={500} lineClamp={1}>
{t(`kind.${getWeatherKind(day.weatherCode)}`)}
</Text>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz="lg" fw={700}>
{getPreferredUnit(
day.maxTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{getPreferredUnit(
day.minTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
<Group className={classes.dailyCardFacts} justify="space-between" gap={4} wrap="nowrap">
<Group gap={3} wrap="nowrap">
<IconDroplets size={14} aria-hidden />
<Text className={classes.dailyCardFact} fz={11}>
{day.precipitationProbability ?? "?"}%
</Text>
</Group>
<Group gap={3} wrap="nowrap">
<IconWind size={14} aria-hidden />
<Text className={classes.dailyCardFact} fz={11}>
{getPreferredWindSpeed(day.maxWindSpeed, options.useImperialSpeed)} {speedUnit}
</Text>
</Group>
</Group>
</Stack>
</Paper>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.dailyDetailsDropdown}>
<DailyWeatherDetails
animateIcons={options.animateIcons}
day={day}
dateFormat={options.dateFormat}
disableTemperatureDecimals={options.disableTemperatureDecimals}
isFahrenheit={options.isFormatFahrenheit}
showHumidity={options.showHumidity}
timeZone={weather.timezone}
useImperialSpeed={options.useImperialSpeed}
/>
</Popover.Dropdown>
</Popover>
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
interface HourlyDetailProps {
locale: string;
showHumidity: boolean;
speedUnit: string;
temperatureUnit: string;
timeZone: string;
useImperialSpeed: boolean;
}
const HourlyWeatherTable = ({
hourly,
locale,
showHumidity,
speedUnit,
temperatureUnit,
timeZone,
useImperialSpeed,
}: HourlyDetailProps & { hourly: HourlyChartRow[] }) => {
const t = useScopedI18n("widget.weather");
return (
<table>
<caption>{t("advanced.hourlyForecast")}</caption>
<thead>
<tr>
<th>{t("advanced.time")}</th>
<th>{t("advanced.conditions")}</th>
<th>{t("advanced.temperature")}</th>
<th>{t("advanced.feelsLikeLabel")}</th>
<th>{t("advanced.precipitationChance")}</th>
{showHumidity && <th>{t("advanced.humidity")}</th>}
<th>{t("advanced.wind", { unit: speedUnit })}</th>
</tr>
</thead>
<tbody>
{hourly.map((hour) => (
<tr key={hour.observedAt}>
<td>{getPreferredTime(hour.observedAt, locale, timeZone)}</td>
<td>{hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`)}</td>
<td>{hour.temperature === null ? "?" : `${hour.temperature.toFixed(1)}${temperatureUnit}`}</td>
<td>
{hour.apparentTemperature === null ? "?" : `${hour.apparentTemperature.toFixed(1)}${temperatureUnit}`}
</td>
<td>{hour.precipitationProbability === null ? "?" : `${hour.precipitationProbability}%`}</td>
{showHumidity && <td>{hour.relativeHumidity === null ? "?" : `${hour.relativeHumidity}%`}</td>}
<td>
{getPreferredWindSpeed(hour.windSpeed, useImperialSpeed)} /{" "}
{getPreferredWindSpeed(hour.windGusts, useImperialSpeed)} {speedUnit}
</td>
</tr>
))}
</tbody>
</table>
);
};
@@ -3,7 +3,9 @@ import "../widgets-common.css";
import { WeatherIcon } from "./icon";
interface AnimatedWeatherIconProps {
animated?: boolean;
code: number;
isDay?: boolean;
size?: string | number;
}
@@ -17,10 +19,11 @@ const getAnimationClass = (code: number): string => {
return "";
};
export const AnimatedWeatherIcon = ({ code, size = 26 }: AnimatedWeatherIconProps) => {
export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26 }: AnimatedWeatherIconProps) => {
const animationClass = animated ? getAnimationClass(code) : "";
return (
<span className={`weather-anim-wrapper ${getAnimationClass(code)}`}>
<WeatherIcon code={code} size={size} />
<span className={`weather-anim-wrapper ${animationClass}`}>
<WeatherIcon code={code} isDay={isDay} size={size} />
</span>
);
};
+169
View File
@@ -0,0 +1,169 @@
import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import classes from "./component.module.css";
import { DailyWeatherDetails } from "./details";
import type { WeatherData } from "./format";
import { getPreferredDate, getPreferredUnit, getPreferredWindSpeed } from "./format";
import { formatWeatherDate, WeatherDescription } from "./icon";
import { getCompactWeatherLayout } from "./layout";
interface CompactWeatherProps {
height: number;
isEditMode: boolean;
options: WidgetProps<"weather">["options"];
weather: WeatherData;
width: number;
}
export const CompactWeather = ({ height, isEditMode, options, weather, width }: CompactWeatherProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
const layout = getCompactWeatherLayout(width, height, options.hasForecast, options.forecastDayCount);
const today = weather.daily[0];
const speedUnit = options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour");
const contentGap = layout.tier === "micro" ? 2 : 4;
const contentPadding = layout.tier === "roomy" ? "xs" : 4;
return (
<Stack
className={classes.compactRoot}
data-tier={layout.tier}
align="center"
justify="center"
gap={contentGap}
h="100%"
w="100%"
p={contentPadding}
>
<Group className="weather-day-group" gap={layout.tier === "micro" ? 2 : "xs"} wrap="nowrap" justify="center">
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton className={classes.detailsButton} aria-label={t("details")} disabled={isEditMode}>
<AnimatedWeatherIcon
animated={options.animateIcons}
code={weather.current.weatherCode}
isDay={weather.current.isDay}
size={layout.tier === "micro" ? 18 : 32}
/>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />
</Popover.Dropdown>
</Popover>
<Stack gap={0} align={layout.tier === "micro" ? "center" : "flex-start"}>
<Text fz={layout.tier === "micro" ? 22 : 30} fw={600} lh={1.05}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
{layout.showCondition && <WeatherDescription weatherOnly weatherCode={weather.current.weatherCode} />}
</Stack>
</Group>
{layout.showHighLow && today && (
<Group className="weather-max-min-temp-group" gap="sm" wrap="nowrap">
<Group gap={3} wrap="nowrap">
<IconArrowUpRight size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm">
{getPreferredUnit(today.maxTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
</Group>
<Group gap={3} wrap="nowrap">
<IconArrowDownRight size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm">
{getPreferredUnit(today.minTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
</Group>
</Group>
)}
{layout.showSecondary && !options.hasForecast && (
<Group className={classes.compactMetrics} gap="sm" justify="center" wrap="wrap">
{options.showHumidity && (
<Group className="weather-humidity-group" gap={4} wrap="nowrap">
<IconDroplets size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">{weather.current.relativeHumidity}%</Text>
</Group>
)}
{options.showCurrentWindSpeed && (
<Group className="weather-current-wind-speed-group" gap={4} wrap="nowrap">
<IconWind size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">
{getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed)} {speedUnit}
</Text>
</Group>
)}
</Group>
)}
{layout.forecastDays > 0 && (
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" gap={2}>
{weather.daily.slice(0, layout.forecastDays).map((day, index) => (
<Popover key={day.date} position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton
className={classes.forecastButton}
aria-label={t("detailsFor", {
date: formatWeatherDate(day.date, locale, options.dateFormat),
})}
disabled={isEditMode}
>
<Stack
component="span"
className={`weather-forecast-day-stack weather-forecast-day${index}`}
gap={0}
align="center"
>
<Text component="span" size="sm" fw={600} tt="capitalize">
{getPreferredDate(day.date, locale, { weekday: "short" })}
</Text>
<AnimatedWeatherIcon animated={options.animateIcons} code={day.weatherCode} size={18} />
<Text component="span" size="sm">
{getPreferredUnit(
day.maxTemperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Stack>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.dailyDetailsDropdown}>
<DailyWeatherDetails
animateIcons={options.animateIcons}
day={day}
dateFormat={options.dateFormat}
disableTemperatureDecimals={options.disableTemperatureDecimals}
isFahrenheit={options.isFormatFahrenheit}
showHumidity={options.showHumidity}
timeZone={weather.timezone}
useImperialSpeed={options.useImperialSpeed}
/>
</Popover.Dropdown>
</Popover>
))}
</Group>
)}
{layout.showCity && options.showCity && (
<Group className="weather-city-group" gap={4} wrap="nowrap" maw="100%">
<IconMapPin size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm" truncate>
{options.location.name}
</Text>
</Group>
)}
</Stack>
);
};
@@ -1,5 +1,85 @@
.weatherSurface {
overflow: hidden;
}
.weatherContent {
min-width: 0;
min-height: 0;
}
.compactRoot {
overflow: hidden;
}
.compactRoot[data-tier="micro"] {
flex-direction: row;
}
.compactMetrics {
min-width: 0;
}
.advancedRoot {
min-width: 0;
}
.locationSummary {
max-width: min(100%, 320px);
}
.metricCard,
.dailyCard,
.detailMetric,
.solarDetails {
background: color-mix(in srgb, var(--mantine-color-body) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
}
.dailyCard {
transition:
background-color 120ms ease,
border-color 120ms ease;
}
.dailyCardButton:hover .dailyCard,
.dailyCardButton:focus-visible .dailyCard {
background: color-mix(in srgb, var(--mantine-primary-color-light) 30%, var(--mantine-color-body));
border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 45%, var(--mantine-color-default-border));
}
.dailyDetailsDropdown {
width: min(390px, calc(100vw - 32px));
padding: var(--mantine-spacing-sm);
}
.dailyDetails {
min-width: 0;
}
.dailyDetailsTemperature {
flex-shrink: 0;
max-width: 132px;
}
.dailyCardFacts {
min-width: 0;
}
.dailyCardFact {
white-space: nowrap;
}
.detailMetric {
min-width: 0;
}
.solarDetails {
background: color-mix(in srgb, var(--mantine-color-yellow-light) 24%, var(--mantine-color-body));
}
.detailsButton,
.forecastButton {
.forecastButton,
.dailyCardButton {
color: inherit;
border-radius: var(--mantine-radius-sm);
}
@@ -12,20 +92,48 @@
min-height: 24px;
}
.forecastButton {
.forecastButton,
.dailyCardButton {
display: block;
}
.dailyCardButton,
.dailyCard {
width: 100%;
height: 100%;
}
.detailsButton:focus-visible,
.forecastButton:focus-visible {
.forecastButton:focus-visible,
.dailyCardButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.errorIndicator {
z-index: 2;
}
@media (any-pointer: coarse) {
.detailsButton,
.forecastButton {
.forecastButton,
.dailyCardButton {
min-width: 44px;
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.dailyCard {
transition: none;
}
}
@media (forced-colors: active) {
.metricCard,
.dailyCard,
.detailMetric,
.solarDetails {
border: 1px solid CanvasText;
}
}
+66 -9
View File
@@ -1,26 +1,25 @@
import { describe, expect, it } from "vitest";
import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component";
import { reduceWidgetOptionsWithDefinition } from "../manifest";
import { definition } from ".";
import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format";
import { formatWeatherDate } from "./icon";
import { getAdvancedWeatherLayout, getCompactWeatherLayout } from "./layout";
describe("weather formatting", () => {
it("preserves valid zero temperatures", () => {
it("preserves valid zero values", () => {
expect(getPreferredUnit(0)).toBe("0.0°C");
expect(getPreferredUnit(0, true, true)).toBe("32°F");
expect(getPreferredWindSpeed(0)).toBe("0.0");
});
it("keeps zero wind speeds and missing forecast values truthful", () => {
expect(getPreferredWindSpeed(0)).toBe("0.0");
it("keeps missing and invalid values truthful", () => {
expect(getPreferredUnit(null)).toBe("?");
expect(getPreferredWindSpeed(undefined)).toBe("?");
expect(getPreferredTime(undefined, "en-US")).toBe("?");
expect(getPreferredTime("invalid", "en-US")).toBe("?");
});
it("limits compact forecasts by both axes", () => {
expect(getVisibleForecastDays(7, 120, 100)).toBe(2);
expect(getVisibleForecastDays(7, 600, 100)).toBe(3);
});
it.each([
["dddd, MMMM D", "Monday, August 3"],
["dddd, D MMMM", "Monday, 3 August"],
@@ -34,3 +33,61 @@ describe("weather formatting", () => {
expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected);
});
});
describe("weather layout", () => {
it("keeps micro widgets focused on current temperature", () => {
expect(getCompactWeatherLayout(120, 100, true, 7)).toEqual({
tier: "micro",
forecastDays: 0,
showCity: false,
showCondition: false,
showHighLow: false,
showSecondary: false,
});
});
it("caps compact forecasts by size and saved preference", () => {
expect(getCompactWeatherLayout(200, 200, true, 7).forecastDays).toBe(2);
expect(getCompactWeatherLayout(424, 200, true, 3).forecastDays).toBe(3);
expect(getCompactWeatherLayout(640, 360, true, 7).forecastDays).toBe(7);
expect(getCompactWeatherLayout(640, 360, false, 7).forecastDays).toBe(0);
});
it("adapts the advanced view to mobile and desktop surfaces", () => {
expect(getAdvancedWeatherLayout(374, 624)).toMatchObject({
dailyColumns: 2,
hourlyTickStep: 6,
showChartAxes: false,
});
expect(getAdvancedWeatherLayout(800, 560)).toMatchObject({
chartHeight: 200,
dailyColumns: 4,
hourlyTickStep: 3,
showChartAxes: true,
});
});
});
describe("weather defaults", () => {
it("starts rich and static while preserving saved false values", () => {
const options = definition.createOptions();
expect(options.showCity.defaultValue).toBe(true);
expect(options.hasForecast.defaultValue).toBe(true);
expect(options.forecastDayCount.defaultValue).toBe(3);
expect(options.animateIcons.defaultValue).toBe(false);
expect(options.showHumidity.defaultValue).toBe(true);
const reduced = reduceWidgetOptionsWithDefinition(definition, {} as never, {
showCity: false,
hasForecast: false,
showHumidity: false,
isFormatFahrenheit: true,
});
expect(reduced).toMatchObject({
showCity: false,
hasForecast: false,
showHumidity: false,
isFormatFahrenheit: true,
});
});
});
+16 -427
View File
@@ -1,32 +1,20 @@
"use client";
import { useId } from "react";
import { Box, Group, Popover, Stack, Table, Text, UnstyledButton } from "@mantine/core";
import {
IconArrowDownRight,
IconArrowUpRight,
IconDroplets,
IconMapPin,
IconSunrise,
IconSunset,
IconWind,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
import { Box } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import { formatWeatherDate, WeatherDescription } from "./icon";
import { AdvancedWeather } from "./advanced";
import classes from "./component.module.css";
import { CompactWeather } from "./compact";
export { getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format";
export { getCompactWeatherLayout } from "./layout";
export default function WeatherWidget({
isEditMode,
@@ -46,419 +34,20 @@ export default function WeatherWidget({
if (isInitialWidgetQueryPending(weatherQuery)) return <WidgetQueryLoadingState />;
if (!weather) return <WidgetEmptyState />;
const content =
displayMode === "advanced" ? (
<AdvancedWeather weather={weather} options={options} />
) : (
<Stack
align="center"
gap="sm"
justify="center"
w="100%"
h="100%"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
{options.hasForecast ? (
<WeeklyForecast
weather={weather}
options={options}
isEditMode={isEditMode}
maxDays={getVisibleForecastDays(options.forecastDayCount, width, height)}
/>
) : (
<DailyWeather weather={weather} options={options} isEditMode={isEditMode} />
)}
</Stack>
);
return (
<Box h="100%" w="100%" pos="relative">
{content}
<Box className={classes.weatherSurface} h="100%" w="100%" pos="relative">
<Box className={classes.weatherContent} h="100%" w="100%" pos="relative">
{displayMode === "advanced" ? (
<AdvancedWeather height={height} options={options} weather={weather} width={width} />
) : (
<CompactWeather height={height} isEditMode={isEditMode} options={options} weather={weather} width={width} />
)}
</Box>
{weatherQuery.error && (
<Box pos="absolute" top={4} right={4}>
<Box pos="absolute" top={4} right={4} className={classes.errorIndicator}>
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
</Box>
)}
</Box>
);
}
interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options"> {
weather: NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
}
interface CompactWeatherProps extends WeatherProps {
isEditMode: boolean;
}
const AdvancedWeather = ({ options, weather }: WeatherProps) => {
const forecastHeadingId = useId();
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
return (
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" gap="sm" wrap="wrap">
<Group gap="sm" wrap="nowrap">
<AnimatedWeatherIcon size={42} code={weather.current.weathercode} />
<Stack gap={0}>
<Text fz="xl" fw={700} lh={1.1}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</Stack>
</Group>
<Group gap="lg" wrap="wrap">
<Group gap={5} wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm">{options.location.name}</Text>
</Group>
<Group gap={5} wrap="nowrap">
<IconWind size="var(--mantine-font-size-md)" aria-hidden />
<Text size="sm">
{t("currentWindSpeed", {
currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
unit: options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour"),
})}
</Text>
</Group>
</Group>
</Group>
<Text id={forecastHeadingId} component="h3" size="sm" fw={600}>
{t("advanced.forecast")}
</Text>
<Table.ScrollContainer
minWidth={680}
maxHeight="100%"
style={{ flex: 1, minHeight: 0 }}
scrollAreaProps={{ type: "auto", offsetScrollbars: "present" }}
>
<Table
aria-labelledby={forecastHeadingId}
striped
highlightOnHover
verticalSpacing="xs"
horizontalSpacing="sm"
stickyHeader
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("advanced.day")}</Table.Th>
<Table.Th>{t("advanced.conditions")}</Table.Th>
<Table.Th>{t("advanced.temperature")}</Table.Th>
<Table.Th>{t("advanced.humidity")}</Table.Th>
<Table.Th>
{t("advanced.wind", {
unit: options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour"),
})}
</Table.Th>
<Table.Th>{t("advanced.sun")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{weather.daily.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text size="sm" c="dimmed" ta="center">
{t("advanced.noForecast")}
</Text>
</Table.Td>
</Table.Tr>
) : (
weather.daily.map((dayWeather) => (
<Table.Tr key={dayWeather.time}>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600} tt="capitalize">
{formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })}
</Text>
<Text size="xs" c="dimmed">
{formatLocalizedDate(dayWeather.time, locale, { month: "short", day: "numeric" })}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<AnimatedWeatherIcon size={20} code={dayWeather.weatherCode} />
<WeatherDescription weatherOnly weatherCode={dayWeather.weatherCode} />
</Group>
</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<IconArrowUpRight size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">
{getPreferredUnit(
dayWeather.maxTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<IconArrowDownRight size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">
{getPreferredUnit(
dayWeather.minTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={5} wrap="nowrap">
<IconDroplets size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={5} wrap="nowrap">
<IconWind size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">
{getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
{getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Group gap={3} wrap="nowrap">
<IconSunrise size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunrise, locale)}</Text>
</Group>
<Text size="sm" c="dimmed">
/
</Text>
<Group gap={3} wrap="nowrap">
<IconSunset size="var(--mantine-font-size-sm)" aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunset, locale)}</Text>
</Group>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
);
};
const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
return (
<>
<Group className="weather-day-group" gap="sm">
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton className={classes.detailsButton} aria-label={t("details")} disabled={isEditMode}>
<AnimatedWeatherIcon size={30} code={weather.current.weathercode} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</Popover.Dropdown>
</Popover>
<Text fz={30}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
<Stack gap="xs" align="center">
{options.showCurrentWindSpeed && (
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
<IconWind size="var(--mantine-font-size-md)" />
<Text fz={16}>
{t("currentWindSpeed", {
currentWindSpeed: (options.useImperialSpeed
? metricToImperial(weather.current.windspeed)
: weather.current.windspeed
).toFixed(1),
unit: options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour"),
})}
</Text>
</Group>
)}
{weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size="var(--mantine-font-size-md)" />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group>
)}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<IconArrowUpRight size="var(--mantine-font-size-md)" />
<Text fz={16}>
{getPreferredUnit(
weather.daily[0]?.maxTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<IconArrowDownRight size="var(--mantine-font-size-md)" />
<Text fz={16}>
{getPreferredUnit(
weather.daily[0]?.minTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
</Group>
</Stack>
{options.showCity && (
<>
<Group className="weather-city-group" wrap="nowrap" gap="xs">
<IconMapPin size="var(--mantine-font-size-md)" />
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
{options.location.name}
</Text>
</Group>
</>
)}
</>
);
};
const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) => {
const t = useScopedI18n("widget.weather");
return (
<>
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
{options.showCity && (
<Group gap="xs" wrap="nowrap">
<IconMapPin size="var(--mantine-font-size-md)" />
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
{options.location.name}
</Text>
</Group>
)}
<Group gap="xs" wrap="nowrap">
<Popover position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton className={classes.detailsButton} aria-label={t("details")} disabled={isEditMode}>
<AnimatedWeatherIcon size={16} code={weather.current.weathercode} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</Popover.Dropdown>
</Popover>
<Text fz={16}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
</Group>
<Forecast weather={weather} options={options} isEditMode={isEditMode} maxDays={maxDays} />
</>
);
};
function Forecast({ weather, options, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) {
const dateFormat = options.dateFormat;
const t = useScopedI18n("widget.weather");
const locale = useCurrentIntlLocale();
return (
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="sm">
{weather.daily.slice(0, maxDays).map((dayWeather, index) => (
<Popover key={dayWeather.time} position="bottom" withArrow shadow="md">
<Popover.Target>
<UnstyledButton
className={classes.forecastButton}
aria-label={t("detailsFor", { date: formatWeatherDate(dayWeather.time, locale, dateFormat) })}
disabled={isEditMode}
>
<Stack
component="span"
className={combineClasses(
"weather-forecast-day-stack",
`weather-forecast-day${index}`,
`weather-forecast-weekday${dayjs(dayWeather.time).day()}`,
)}
gap="0"
align="center"
>
<Text component="span" fz="xl">
{formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })}
</Text>
<AnimatedWeatherIcon size={16} code={dayWeather.weatherCode} />
<Text component="span" fz={16}>
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
</Stack>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<WeatherDescription
useImperialSpeed={options.useImperialSpeed}
dateFormat={dateFormat}
time={dayWeather.time}
weatherCode={dayWeather.weatherCode}
maxTemp={getPreferredUnit(
dayWeather.maxTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
minTemp={getPreferredUnit(
dayWeather.minTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
sunrise={getPreferredTime(dayWeather.sunrise, locale)}
sunset={getPreferredTime(dayWeather.sunset, locale)}
maxWindSpeed={dayWeather.maxWindSpeed}
maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/>
</Popover.Dropdown>
</Popover>
))}
</Group>
);
}
export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
value !== undefined
? isFahrenheit
? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F`
: `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
: "?";
export const getPreferredWindSpeed = (value: number | undefined, useImperialSpeed = false): string =>
value === undefined ? "?" : (useImperialSpeed ? metricToImperial(value) : value).toFixed(1);
export const getPreferredTime = (value: string | undefined, locale = "en-US"): string =>
value ? formatLocalizedTime(value, locale) : "?";
export const getVisibleForecastDays = (configuredDays: number, width: number, height: number): number => {
const widthBudget = Math.max(1, Math.floor(width / 58));
const heightBudget = height < 120 ? 3 : configuredDays;
return Math.min(configuredDays, widthBudget, heightBudget);
};
+161
View File
@@ -0,0 +1,161 @@
import type { ReactNode } from "react";
import { Group, Paper, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import {
IconDroplets,
IconGauge,
IconSunHigh,
IconSunrise,
IconSunset,
IconTemperature,
IconWind,
} from "@tabler/icons-react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import classes from "./component.module.css";
import type { DailyWeatherData } from "./format";
import { getPreferredDaylightDuration, getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format";
import { formatWeatherDate, getWeatherKind } from "./icon";
interface DailyWeatherDetailsProps {
animateIcons: boolean;
day: DailyWeatherData;
disableTemperatureDecimals: boolean;
isFahrenheit: boolean;
dateFormat: WidgetProps<"weather">["options"]["dateFormat"];
showHumidity: boolean;
timeZone: string;
useImperialSpeed: boolean;
}
interface DetailMetricProps {
icon: ReactNode;
label: string;
value: ReactNode;
}
const DetailMetric = ({ icon, label, value }: DetailMetricProps) => (
<Paper className={classes.detailMetric} p="xs" radius="md">
<Group gap="xs" wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" size={28} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} miw={0}>
<Text size="xs" c="dimmed" lh={1.25}>
{label}
</Text>
<Text size="sm" fw={600} lh={1.35}>
{value}
</Text>
</Stack>
</Group>
</Paper>
);
const SolarMetric = ({ icon, label, value }: DetailMetricProps) => (
<Stack gap={2} align="center" ta="center">
<ThemeIcon variant="transparent" color="yellow" size={22}>
{icon}
</ThemeIcon>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Stack>
);
export const DailyWeatherDetails = ({
animateIcons,
day,
dateFormat,
disableTemperatureDecimals,
isFahrenheit,
showHumidity,
timeZone,
useImperialSpeed,
}: DailyWeatherDetailsProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
const speedUnit = useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour");
return (
<Stack gap="sm" className={classes.dailyDetails}>
<Group justify="space-between" align="center" gap="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap" miw={0}>
<ThemeIcon variant="light" size={46} radius="xl">
<AnimatedWeatherIcon animated={animateIcons} code={day.weatherCode} size={28} />
</ThemeIcon>
<Stack gap={0} miw={0}>
<Text fw={700} truncate>
{formatWeatherDate(day.date, locale, dateFormat)}
</Text>
<Text size="sm" c="dimmed">
{t(`kind.${getWeatherKind(day.weatherCode)}`)}
</Text>
</Stack>
</Group>
<Text size="sm" fw={600} ta="right" className={classes.dailyDetailsTemperature}>
{t("advanced.highLow", {
maximum: getPreferredUnit(day.maxTemperature, isFahrenheit, disableTemperatureDecimals),
minimum: getPreferredUnit(day.minTemperature, isFahrenheit, disableTemperatureDecimals),
})}
</Text>
</Group>
<SimpleGrid cols={2} spacing={6}>
<DetailMetric
icon={<IconTemperature size={16} aria-hidden />}
label={t("advanced.detailLabel.feelsLike")}
value={`${getPreferredUnit(day.maxApparentTemperature, isFahrenheit, disableTemperatureDecimals)} / ${getPreferredUnit(day.minApparentTemperature, isFahrenheit, disableTemperatureDecimals)}`}
/>
<DetailMetric
icon={<IconDroplets size={16} aria-hidden />}
label={t("advanced.detailLabel.precipitation")}
value={`${day.precipitationProbability ?? "?"}% · ${day.precipitation?.toFixed(1) ?? "?"} mm`}
/>
{showHumidity && (
<DetailMetric
icon={<IconGauge size={16} aria-hidden />}
label={t("advanced.detailLabel.humidity")}
value={`${day.averageHumidity ?? "?"}%`}
/>
)}
<DetailMetric
icon={<IconWind size={16} aria-hidden />}
label={t("advanced.detailLabel.wind")}
value={`${getPreferredWindSpeed(day.maxWindSpeed, useImperialSpeed)} / ${getPreferredWindSpeed(day.maxWindGusts, useImperialSpeed)} ${speedUnit}`}
/>
<DetailMetric
icon={<IconSunHigh size={16} aria-hidden />}
label={t("advanced.detailLabel.uv")}
value={day.uvIndex?.toFixed(1) ?? "?"}
/>
</SimpleGrid>
<Paper className={classes.solarDetails} p="xs" radius="md">
<SimpleGrid cols={3} spacing="xs">
<SolarMetric
icon={<IconSunrise size={17} aria-hidden />}
label={t("dailyForecast.sunrise")}
value={getPreferredTime(day.sunriseAt, locale, timeZone)}
/>
<SolarMetric
icon={<IconSunset size={17} aria-hidden />}
label={t("dailyForecast.sunset")}
value={getPreferredTime(day.sunsetAt, locale, timeZone)}
/>
<SolarMetric
icon={<IconSunHigh size={17} aria-hidden />}
label={t("advanced.detailLabel.daylight")}
value={getPreferredDaylightDuration(day.daylightDuration)}
/>
</SimpleGrid>
</Paper>
</Stack>
);
};
+60
View File
@@ -0,0 +1,60 @@
import type { RouterOutputs } from "@homarr/api";
import { formatDuration, metricToImperial } from "@homarr/common";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
export type WeatherData = NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
export type DailyWeatherData = WeatherData["daily"][number];
export const toPreferredTemperature = (value: number | null | undefined, isFahrenheit = false): number | null => {
if (value === null || value === undefined) return null;
if (isFahrenheit) return value * (9 / 5) + 32;
return value;
};
export const getPreferredUnit = (
value: number | null | undefined,
isFahrenheit = false,
disableTemperatureDecimals = false,
): string => {
const preferred = toPreferredTemperature(value, isFahrenheit);
if (preferred === null) return "?";
return `${preferred.toFixed(disableTemperatureDecimals ? 0 : 1)}°${isFahrenheit ? "F" : "C"}`;
};
export const getPreferredWindSpeed = (value: number | null | undefined, useImperialSpeed = false): string => {
if (value === null || value === undefined) return "?";
return (useImperialSpeed ? metricToImperial(value) : value).toFixed(1);
};
export const getPreferredTime = (value: string | null | undefined, locale = "en-US", timeZone?: string): string => {
if (!value) return "?";
return formatLocalizedTime(value, locale, { timeZone });
};
export const getPreferredDate = (value: string, locale: string, options: Intl.DateTimeFormatOptions): string =>
formatLocalizedDate(value, locale, options);
export const getPreferredDaylightDuration = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "?";
return formatDuration(value * 1000);
};
export interface HourlyChartRow {
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;
}
export const getHourlyChartData = (weather: WeatherData, isFahrenheit: boolean): HourlyChartRow[] =>
weather.hourly.map((hour) => ({
...hour,
temperature: toPreferredTemperature(hour.temperature, isFahrenheit),
apparentTemperature: toPreferredTemperature(hour.apparentTemperature, isFahrenheit),
}));
+22 -7
View File
@@ -23,6 +23,7 @@ import type { WidgetProps } from "../definition";
interface WeatherIconProps {
code: number;
isDay?: boolean;
size?: string | number;
}
@@ -32,12 +33,16 @@ interface WeatherIconProps {
* @param size size of the icon, accepts relative sizes too
* @returns Icon corresponding to the weather code
*/
export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
export const WeatherIcon = ({ code, isDay = true, size = 50 }: WeatherIconProps) => {
if (code === 0 && !isDay) return <IconMoon style={{ float: "left" }} size={size} />;
const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
return <Icon style={{ float: "left" }} size={size} />;
};
export const getWeatherKind = (code: number) =>
(weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather).name;
interface WeatherDescriptionProps {
weatherOnly?: boolean;
useImperialSpeed?: boolean;
@@ -84,7 +89,7 @@ export const WeatherDescription = ({
const tCommon = useScopedI18n("common");
const locale = useCurrentIntlLocale();
const { name } = weatherDefinitions.find((definition) => definition.codes.includes(weatherCode)) ?? unknownWeather;
const name = getWeatherKind(weatherCode);
if (weatherOnly) {
return <Text fz="16px">{t(`kind.${name}`)}</Text>;
@@ -95,12 +100,22 @@ export const WeatherDescription = ({
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
<Text fz="16px">{t(`kind.${name}`)}</Text>
<List>
<List.Item icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
<List.Item icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
<List.Item
icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}
>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
<List.Item
icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}
>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item
icon={<IconSun size="var(--mantine-font-size-sm)" />}
>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item
icon={<IconMoon size="var(--mantine-font-size-sm)" />}
>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && (
<List.Item icon={<IconDroplets size="var(--mantine-font-size-sm)" />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
<List.Item icon={<IconDroplets size="var(--mantine-font-size-sm)" />}>
{t("dailyForecast.humidity", { humidity })}
</List.Item>
)}
{maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
+6 -3
View File
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
icon: IconCloud,
supportsAdvancedFocus: true,
queryKey: [["widget", "weather", "atLocation"]],
refetchInterval: 600,
queryMatcher: ({ input }, scope) => {
const location = scope.options.location;
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
@@ -24,6 +25,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
(factory) => ({
isFormatFahrenheit: factory.switch(),
disableTemperatureDecimals: factory.switch(),
animateIcons: factory.switch(),
showHumidity: factory.switch({ defaultValue: true, withDescription: true }),
showCurrentWindSpeed: factory.switch({ withDescription: true }),
useImperialSpeed: factory.switch(),
location: factory.location({
@@ -47,10 +50,10 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(),
hasForecast: factory.switch(),
showCity: factory.switch({ defaultValue: true }),
hasForecast: factory.switch({ defaultValue: true }),
forecastDayCount: factory.slider({
defaultValue: 5,
defaultValue: 3,
validate: z.number().min(1).max(7),
step: 1,
withDescription: true,
+58
View File
@@ -0,0 +1,58 @@
export type CompactWeatherTier = "micro" | "compact" | "standard" | "roomy";
export interface CompactWeatherLayout {
tier: CompactWeatherTier;
forecastDays: number;
showCity: boolean;
showCondition: boolean;
showHighLow: boolean;
showSecondary: boolean;
}
export const getCompactWeatherLayout = (
width: number,
height: number,
hasForecast: boolean,
configuredDays: number,
): CompactWeatherLayout => {
if (height < 120 || width < 160) {
return {
tier: "micro",
forecastDays: 0,
showCity: false,
showCondition: false,
showHighLow: false,
showSecondary: false,
};
}
if (height < 180 || width < 280) {
return {
tier: "compact",
forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(width / 92))) : 0,
showCity: width >= 220,
showCondition: true,
showHighLow: true,
showSecondary: false,
};
}
const roomy = width >= 500 && height >= 280;
const availableForecastWidth = Math.max(0, width - 24);
return {
tier: roomy ? "roomy" : "standard",
forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(availableForecastWidth / 64))) : 0,
showCity: true,
showCondition: true,
showHighLow: true,
showSecondary: true,
};
};
export const getAdvancedWeatherLayout = (width: number, height: number) => ({
chartHeight: height >= 520 ? 200 : 150,
dailyColumns: width >= 700 ? 4 : width >= 480 ? 3 : width >= 320 ? 2 : 1,
hourlyTickStep: width >= 520 ? 3 : 6,
metricColumns: width >= 700 ? 3 : width >= 420 ? 2 : 1,
showChartAxes: width >= 520,
});