diff --git a/apps/docs/docs/widgets/air-quality/index.mdx b/apps/docs/docs/widgets/air-quality/index.mdx
new file mode 100644
index 000000000..c1b7dd4bd
--- /dev/null
+++ b/apps/docs/docs/widgets/air-quality/index.mdx
@@ -0,0 +1,47 @@
+---
+title: "Air Quality & UV"
+description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
+hide_title: true
+---
+
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { airQualityWidget } from ".";
+
+
+
+The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
+dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
+view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
+
+Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
+locale region is the United States and the European scale everywhere else. The selected scale is always shown next
+to the value. Pollen values are individual measured concentrations; the widget does not combine them into a single
+pollen index.
+
+The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
+micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
+
+
+
+### Adding the widget
+
+
+
+### Configuration
+
+
+
+### Data source
+
+Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
+seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
+monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
+server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
+hours instead of replacing it with an empty state.
+
+Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
+service, use `https://air-quality-api.open-meteo.com`.
diff --git a/apps/docs/docs/widgets/air-quality/index.ts b/apps/docs/docs/widgets/air-quality/index.ts
new file mode 100644
index 000000000..e1d3761ff
--- /dev/null
+++ b/apps/docs/docs/widgets/air-quality/index.ts
@@ -0,0 +1,43 @@
+import type { WidgetDefinition } from "@site/src/types";
+import { IconWind } from "@tabler/icons-react";
+
+export const airQualityWidget: WidgetDefinition = {
+ icon: IconWind,
+ name: "Air Quality & UV",
+ description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
+ path: "../../widgets/air-quality",
+ configuration: {
+ items: [
+ {
+ name: "Air quality location",
+ description: "Location used for air quality, UV and pollen data",
+ values: "Select a location through search or longitude/latitude",
+ defaultValue: "Paris / 48.85341, 2.3488",
+ },
+ {
+ name: "AQI standard",
+ description: "Choose the European or US air quality scale, or select one from the viewer's locale",
+ values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
+ defaultValue: "Automatic for locale",
+ },
+ {
+ name: "Show UV index",
+ description: "Display the current UV index and its forecast",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show pollutant concentrations",
+ description: "Display particulate matter and gaseous pollutant concentrations",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show pollen concentrations",
+ description: "Display available pollen concentrations",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ ],
+ },
+};
diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx
index 6ec75e9f6..1f453d33f 100644
--- a/apps/docs/docs/widgets/clock/index.mdx
+++ b/apps/docs/docs/widgets/clock/index.mdx
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
The clock widget displays the current date and time in a customizable format.
+Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
+Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
+London, Sydney, and UTC are also available as quick presets.
+
+The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
+comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
+time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
+astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
+
+The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
+examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
+24-hour display are selected through those same formats. The date-format selector likewise
+shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
+Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
+rules, including daylight-saving changes.
+
+Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
+default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
+timezone is identified without preventing valid clocks from rendering.
+
### Screenshots
import localClock from "./img/local.png";
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
-
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/clock/index.ts b/apps/docs/docs/widgets/clock/index.ts
index 5cdb1e2a8..f34cae4ed 100644
--- a/apps/docs/docs/widgets/clock/index.ts
+++ b/apps/docs/docs/widgets/clock/index.ts
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
values: { type: "string" },
defaultValue: "-",
},
- {
- name: "24-hour format",
- description: "Use 24-hour format instead of 12-hour format.",
- values: { type: "boolean" },
- defaultValue: "yes",
- },
- {
- name: "Display seconds",
- description: "Whether to show seconds in the clock.",
- values: { type: "boolean" },
- defaultValue: "no",
- },
{
name: "Use fixed timezone",
description: "Select custom timezone to display instead of the client timezone.",
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
},
{
name: "Date format",
- description: "Format of the date displayed below the clock.",
- values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
+ description: "Choose a common Day.js date format with a live example and its tokens.",
+ values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
defaultValue: "dddd, MMMM D",
},
{
- name: "Custom time format",
- description: "Format of the time displayed in the clock.",
- values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
- defaultValue: "-",
+ name: "Time format",
+ description:
+ "Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
+ values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
+ defaultValue: "HH:mm",
},
{
- name: "Custom date format",
- description: "Format of the date displayed below the clock.",
- values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
- defaultValue: "-",
+ name: "Show weather",
+ description: "Show a compact weather summary from an independently configured location.",
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ {
+ name: "Weather location",
+ description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
+ values: "A searched city or custom latitude and longitude",
+ defaultValue: "Brisbane",
+ },
+ {
+ name: "Use Fahrenheit",
+ description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ {
+ name: "Animate weather icon",
+ description: "Animate the weather icon unless the device requests reduced motion.",
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ {
+ name: "World clock cities",
+ description: "Add, label, and order up to six IANA timezones for advanced view.",
+ values: "Up to six labeled IANA timezones",
+ defaultValue: "New York, Paris, and Tokyo",
},
],
},
diff --git a/apps/docs/docs/widgets/countdown/index.mdx b/apps/docs/docs/widgets/countdown/index.mdx
new file mode 100644
index 000000000..268e1b7ba
--- /dev/null
+++ b/apps/docs/docs/widgets/countdown/index.mdx
@@ -0,0 +1,44 @@
+---
+title: "Countdowns"
+description: "Tracks upcoming events and recurring anniversaries."
+hide_title: true
+---
+
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { countdownWidget } from ".";
+
+
+
+The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
+date and time, and select an IANA timezone. Events can be reordered by dragging them.
+
+The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
+the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
+upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
+
+One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
+configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
+If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
+the earlier occurrence is used.
+
+Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
+progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
+
+Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
+requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
+storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
+data resets them. Widget previews do not persist alert preferences.
+
+
+
+### Adding the widget
+
+
+
+### Configuration
+
+
diff --git a/apps/docs/docs/widgets/countdown/index.ts b/apps/docs/docs/widgets/countdown/index.ts
new file mode 100644
index 000000000..4e6d94674
--- /dev/null
+++ b/apps/docs/docs/widgets/countdown/index.ts
@@ -0,0 +1,31 @@
+import { WidgetDefinition } from "@site/src/types";
+import { IconHourglass } from "@tabler/icons-react";
+
+export const countdownWidget: WidgetDefinition = {
+ icon: IconHourglass,
+ name: "Countdowns",
+ description: "Tracks upcoming events and recurring anniversaries.",
+ path: "../../widgets/countdown",
+ configuration: {
+ items: [
+ {
+ name: "Events",
+ description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
+ values: "Up to 20 events",
+ defaultValue: "No events",
+ },
+ {
+ name: "Show progress",
+ description: "Show progress from an optional start date, or between yearly occurrences.",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show seconds",
+ description: "Keep seconds visible for every upcoming event, including events more than a day away.",
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ ],
+ },
+};
diff --git a/apps/docs/docs/widgets/timer/index.mdx b/apps/docs/docs/widgets/timer/index.mdx
new file mode 100644
index 000000000..e1dca36ae
--- /dev/null
+++ b/apps/docs/docs/widgets/timer/index.mdx
@@ -0,0 +1,52 @@
+---
+title: "Timer / Pomodoro"
+description: "Runs a simple timer or a configurable Pomodoro sequence."
+hide_title: true
+---
+
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { timerWidget } from ".";
+
+
+
+The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
+always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
+the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
+
+Timer mode repeats one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
+sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
+focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
+reconciled from their original deadlines after the board returns to the foreground.
+
+Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
+requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
+stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
+guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
+
+Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
+rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
+notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
+supplement the visible timer state rather than replacing it.
+
+When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
+board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
+300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
+Compact controls also step down in size when the widget preview is narrow or short.
+
+Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
+The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
+from the widget context menu.
+
+
+
+### Adding the widget
+
+
+
+### Configuration
+
+
diff --git a/apps/docs/docs/widgets/timer/index.ts b/apps/docs/docs/widgets/timer/index.ts
new file mode 100644
index 000000000..2874cabad
--- /dev/null
+++ b/apps/docs/docs/widgets/timer/index.ts
@@ -0,0 +1,61 @@
+import type { WidgetDefinition } from "@site/src/types";
+import { IconClockPlay } from "@tabler/icons-react";
+
+export const timerWidget: WidgetDefinition = {
+ icon: IconClockPlay,
+ name: "Timer / Pomodoro",
+ description: "Runs a simple timer or a configurable Pomodoro sequence.",
+ path: "../../widgets/timer",
+ configuration: {
+ items: [
+ {
+ name: "Mode",
+ description: "Run one reusable timer or a repeating focus and break sequence",
+ values: { type: "select", options: ["Timer", "Pomodoro"] },
+ defaultValue: "Pomodoro",
+ },
+ {
+ name: "Timer minutes",
+ description: "Duration of the reusable timer",
+ values: "1-1440 minutes",
+ defaultValue: "10",
+ },
+ {
+ name: "Focus minutes",
+ description: "Duration of each Pomodoro focus session",
+ values: "1-180 minutes",
+ defaultValue: "25",
+ },
+ {
+ name: "Short break minutes",
+ description: "Duration of breaks between most focus sessions",
+ values: "1-60 minutes",
+ defaultValue: "5",
+ },
+ {
+ name: "Long break minutes",
+ description: "Duration of the break at the end of a Pomodoro cycle",
+ values: "1-180 minutes",
+ defaultValue: "15",
+ },
+ {
+ name: "Focus sessions before a long break",
+ description: "Number of completed focus sessions in one Pomodoro cycle",
+ values: "1-12 sessions",
+ defaultValue: "4",
+ },
+ {
+ name: "Automatically start breaks",
+ description: "Start the next short or long break when a focus session completes",
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ {
+ name: "Automatically start focus sessions",
+ description: "Start the next focus session when a break completes",
+ values: { type: "boolean" },
+ defaultValue: "no",
+ },
+ ],
+ },
+};
diff --git a/apps/docs/docs/widgets/today/index.mdx b/apps/docs/docs/widgets/today/index.mdx
new file mode 100644
index 000000000..000861d20
--- /dev/null
+++ b/apps/docs/docs/widgets/today/index.mdx
@@ -0,0 +1,34 @@
+---
+title: "Today"
+description: "Displays the current date and progress through the calendar."
+hide_title: true
+---
+
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { todayWidget } from ".";
+
+
+
+The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
+number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
+
+The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
+your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
+unambiguous.
+
+The widget runs entirely in your browser and does not send date or location data to an external service. It follows
+the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
+
+
+
+### Adding the widget
+
+
+
+### Configuration
+
+
diff --git a/apps/docs/docs/widgets/today/index.ts b/apps/docs/docs/widgets/today/index.ts
new file mode 100644
index 000000000..8922d4b6e
--- /dev/null
+++ b/apps/docs/docs/widgets/today/index.ts
@@ -0,0 +1,43 @@
+import type { WidgetDefinition } from "@site/src/types";
+import { IconCalendarWeek } from "@tabler/icons-react";
+
+export const todayWidget: WidgetDefinition = {
+ icon: IconCalendarWeek,
+ name: "Today",
+ description: "Displays the current date and progress through the calendar.",
+ path: "../../widgets/today",
+ configuration: {
+ items: [
+ {
+ name: "Week numbering",
+ description: "Use locale-specific week numbering or ISO 8601 week numbers",
+ values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
+ defaultValue: "Use locale rules",
+ },
+ {
+ name: "Show week number",
+ description: "Display the current week number when the widget has room",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show day of year",
+ description: "Display the current day number within the year",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show quarter",
+ description: "Display the current calendar quarter",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ {
+ name: "Show year progress",
+ description: "Display progress through the current year",
+ values: { type: "boolean" },
+ defaultValue: "yes",
+ },
+ ],
+ },
+};
diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx
index f64f5d754..c03357f87 100644
--- a/apps/docs/docs/widgets/weather/index.mdx
+++ b/apps/docs/docs/widgets/weather/index.mdx
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
-The Weather Widget uses your location to display the current, highest and lowest temperature.
-In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
-The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
-available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
-Meteo response.
+The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
+with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
+temperature readable before removing secondary details or forecast days.
-### Screenshots
+Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
+daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
+temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
+assistive technologies.
-import forecastWeather from "./img/forecast.png";
-import currentWeather from "./img/current.png";
-import advancedWeather from "./img/advanced-view.png";
+The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
+details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
+the standard desktop surface and fewer columns as the surface narrows.
-
+Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
+reduced-motion preference always disables it.
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
### Data source
-We use the Open Meteo API to fetch the weather and locations.
+We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
+identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
+same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
+error indicator.
+
+Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
diff --git a/apps/docs/docs/widgets/weather/index.tsx b/apps/docs/docs/widgets/weather/index.tsx
index ff795f984..836b70ca5 100644
--- a/apps/docs/docs/widgets/weather/index.tsx
+++ b/apps/docs/docs/widgets/weather/index.tsx
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
+ defaultValue: "no",
+ },
+ {
+ name: "Show humidity",
+ description: "Display humidity in current conditions, hourly details, and daily forecasts",
+ values: {
+ type: "boolean",
+ },
defaultValue: "yes",
},
+ {
+ name: "Animate weather icons",
+ description: "Animate condition icons unless the operating system requests reduced motion",
+ values: {
+ type: "boolean",
+ },
+ defaultValue: "no",
+ },
{
name: "Use imperial speed units",
description: "Display wind speed in mph instead of km/h",
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
- defaultValue: "no",
+ defaultValue: "yes",
},
{
name: "Has forecast",
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
- defaultValue: "no",
+ defaultValue: "yes",
},
{
name: "Amount of forecast days",
description: "Number of days to show in the forecast",
values: "1-7",
- defaultValue: "5",
+ defaultValue: "3",
},
],
},
diff --git a/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx b/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx
index da7e58a87..ae19cb0f8 100644
--- a/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx
+++ b/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx
@@ -1,4 +1,4 @@
-import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
+import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
-
- 10.8°C
-
-
-
- 5 km/h
-
-
-
-
-
13.5°C
+
+
+ 18.4°C
+ Paris
-
-
- 6.4°C
+
+
+
+ Mon
+
+ 21°
+
+
+ Tue
+
+ 19°
+
+
+ Wed
+
+ 16°
diff --git a/packages/api/src/router/widgets/air-quality.ts b/packages/api/src/router/widgets/air-quality.ts
new file mode 100644
index 000000000..78cad2f08
--- /dev/null
+++ b/packages/api/src/router/widgets/air-quality.ts
@@ -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);
+ }),
+});
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index ba5987463..01edcbd3b 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -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)),
diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts
index 98416ee3c..f40ac030f 100644
--- a/packages/api/src/router/widgets/weather.ts
+++ b/packages/api/src/router/widgets/weather.ts
@@ -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({
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 92cc02d44..6af7bf14b 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -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"
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index dd3f02e37..2504dda24 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -3,6 +3,10 @@ import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record
= {
clock: "clock",
weather: "weather",
+ today: "today",
+ airQuality: "air-quality",
+ countdown: "countdown",
+ timer: "timer",
app: "app",
iframe: "iframe",
video: "video",
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index 90274f3d8..f3223a186 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -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> = {
+ 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 },
diff --git a/packages/request-handler/src/air-quality.spec.ts b/packages/request-handler/src/air-quality.spec.ts
new file mode 100644
index 000000000..d2b174fa4
--- /dev/null
+++ b/packages/request-handler/src/air-quality.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/request-handler/src/air-quality.ts b/packages/request-handler/src/air-quality.ts
new file mode 100644
index 000000000..6a77a871a
--- /dev/null
+++ b/packages/request-handler/src/air-quality.ts
@@ -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;
+type HourlyValues = z.infer;
+
+const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit, 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();
+
+ 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;
+ }[];
+}
diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts
index b5273c87f..350826fe3 100644
--- a/packages/request-handler/src/lib/widget-request-handler.ts
+++ b/packages/request-handler/src/lib/widget-request-handler.ts
@@ -2,11 +2,14 @@ import { createRequestHandler } from "./request-handler";
interface Options> {
requestAsync: (input: TInput) => Promise;
+ cacheTtlMs?: number;
+ fallbackToStaleOnError?: boolean;
+ staleIfErrorTtlMs?: number;
}
export const createWidgetRequestHandler = >(
options: Options,
) => {
- const inner = createRequestHandler({ requestAsync: options.requestAsync });
+ const inner = createRequestHandler(options);
return inner;
};
diff --git a/packages/request-handler/src/weather.spec.ts b/packages/request-handler/src/weather.spec.ts
new file mode 100644
index 000000000..a5e8e767c
--- /dev/null
+++ b/packages/request-handler/src/weather.spec.ts
@@ -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((_, index) => index);
+ const dailyValues = days.map((_, index) => index);
+ const sunrise = days.map((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();
+ });
+});
diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts
index c2e5151ac..749880b22 100644
--- a/packages/request-handler/src/weather.ts
+++ b/packages/request-handler/src/weather.ts
@@ -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 => {
+ 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¤t_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;
}[];
}
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 31c64fe46..f68859071 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -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"
}
diff --git a/packages/widgets/package.json b/packages/widgets/package.json
index 0bf3bc8cb..034197e44 100644
--- a/packages/widgets/package.json
+++ b/packages/widgets/package.json
@@ -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:",
diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts
index e5f439dde..0e21ee59f 100644
--- a/packages/widgets/src/_inputs/index.ts
+++ b/packages/widgets/src/_inputs/index.ts
@@ -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,
diff --git a/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx b/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx
new file mode 100644
index 000000000..0e837f211
--- /dev/null
+++ b/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx
@@ -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 (
+
+
+ {options.withDescription && (
+
+ {t("description")}
+
+ )}
+
+ }
+ onClick={addEvent}
+ disabled={values.length >= options.maxValues}
+ >
+ {tEditor("add")}
+
+
+ {
+ 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));
+ }}
+ >
+ id)} strategy={verticalListSortingStrategy}>
+
+ {values.map((event) => (
+
+ setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate)))
+ }
+ onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))}
+ />
+ ))}
+
+
+
+
+
+ {tEditor("limit", { count: values.length, maximum: options.maxValues })}
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+
+ onChange({ ...event, label: changeEvent.currentTarget.value })}
+ />
+
+
+
+
+
+
+ {
+ if (timeZone) changeTimeZone(timeZone);
+ }}
+ />
+ ({ value, label: t(`recurrenceOption.${value}`) }))}
+ onChange={(value) => {
+ if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence });
+ }}
+ />
+
+
+ updateWallDateTime(value, "targetUtc")}
+ confirmLabel={t("confirmDateTime")}
+ />
+
+ {event.recurrence === "none" && (
+ <>
+ {
+ if (!changeEvent.currentTarget.checked) {
+ const nextEvent = { ...event };
+ delete nextEvent.startUtc;
+ onChange(nextEvent);
+ return;
+ }
+ onChange({ ...event, startUtc: new Date().toISOString() });
+ }}
+ />
+ {event.startUtc && (
+ updateWallDateTime(value, "startUtc")}
+ confirmLabel={t("confirmDateTime")}
+ />
+ )}
+ >
+ )}
+
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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;
+
+const useEventListTranslation = (kind: WidgetKind) =>
+ useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation;
diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx
index 1b1ffd645..6de7a36a1 100644
--- a/packages/widgets/src/_inputs/widget-select-input.tsx
+++ b/packages/widgets/src/_inputs/widget-select-input.tsx
@@ -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}
/>
diff --git a/packages/widgets/src/_inputs/widget-timezone-list-input.tsx b/packages/widgets/src/_inputs/widget-timezone-list-input.tsx
new file mode 100644
index 000000000..27f5a1ee8
--- /dev/null
+++ b/packages/widgets/src/_inputs/widget-timezone-list-input.tsx
@@ -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(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 (
+
+
+ {options.withDescription && (
+
+ {t("description")}
+
+ )}
+
+
+
+ }
+ onClick={addSelectedTimeZone}
+ disabled={isFull || selectedTimeZone === null}
+ >
+ {tEditor("add")}
+
+
+
+
+ {options.presets.map((preset) => (
+ addCity({ ...preset, id: createId() })}
+ >
+ {preset.label}
+
+ ))}
+
+
+ {
+ 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));
+ }}
+ >
+ id)} strategy={verticalListSortingStrategy}>
+
+ {values.map((city) => (
+
+ setValues(values.map((candidate) => (candidate.id === city.id ? nextCity : candidate)))
+ }
+ onRemove={() => setValues(values.filter((candidate) => candidate.id !== city.id))}
+ />
+ ))}
+
+
+
+
+
+ {tEditor("limit", { count: values.length, maximum: options.maxValues })}
+
+
+
+ );
+};
+
+interface SortableTimezoneProps {
+ kind: WidgetKind;
+ city: OptionTimezone;
+ timeZoneOptions: readonly { value: string; label: string }[];
+ usedTimeZones: ReadonlySet;
+ 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 (
+
+
+
+
+
+ onChange({ ...city, label: event.currentTarget.value })}
+ />
+ ({
+ ...option,
+ disabled: option.value !== city.timeZone && usedTimeZones.has(option.value),
+ }))}
+ value={city.timeZone}
+ onChange={(timeZone) => {
+ if (timeZone) onChange({ ...city, timeZone });
+ }}
+ />
+
+
+
+
+
+ );
+};
+
+type TimezoneListTranslationKey =
+ | "timezone"
+ | "cityLabel"
+ | "searchPlaceholder"
+ | "noResults"
+ | "unsupportedOption"
+ | "add"
+ | "remove"
+ | "reorder"
+ | "limit";
+
+type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record) => string;
+
+const useTimezoneListTranslation = (kind: WidgetKind) =>
+ useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation;
diff --git a/packages/widgets/src/air-quality/component.tsx b/packages/widgets/src/air-quality/component.tsx
new file mode 100644
index 000000000..b891f6d94
--- /dev/null
+++ b/packages/widgets/src/air-quality/component.tsx
@@ -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 ;
+ if (!airQuality) return ;
+
+ return (
+
+ {displayMode === "advanced" ? (
+
+ ) : (
+
+ )}
+ {query.error && (
+
+
+
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ {formatAqi(value, locale)}
+
+
+
+ {t(`category.${category}`)}
+
+
+
+
+ {!layout.tiny && (
+
+ {options.showUv && (
+
+ )}
+ {options.showPollutants && dominant && (
+
+ )}
+
+ )}
+
+ {!layout.tiny && layout.showParticulatesAndPollen && (
+
+ {options.showPollutants && (
+ <>
+
+
+ >
+ )}
+ {options.showPollen && strongestPollen && (
+
+ )}
+
+ )}
+
+ {!layout.tiny && layout.showSparkline && sixHours.length > 1 && (
+
+
+ {t("compact.sixHourTrend")}
+
+ )}
+
+ );
+};
+
+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 (
+
+
+
+
+
+ {formatAqi(value, locale)}
+
+ {t(`category.${category}`)}
+
+
+
+
+
+ {options.location.name}
+
+
+
+ {t("advanced.observedAt", {
+ time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }),
+ })}
+
+
+
+
+
+
+ {options.showUv && (
+
+ )}
+ {options.showPollutants && (
+ <>
+
+
+ >
+ )}
+
+
+ = 800 ? 2 : 1} spacing="md">
+
+
+ {t("advanced.next24Hours")}
+
+ {chartData.length > 0 ? (
+ <>
+
+
+ formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }),
+ }}
+ />
+
+
+
+ {upcoming.map((hour) => (
+
+ {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) })}`
+ : ""}
+
+ ))}
+
+
+ >
+ ) : (
+
+ {t("advanced.noForecast")}
+
+ )}
+
+
+
+ {options.showPollutants && (
+
+
+ {t("advanced.pollutants")}
+
+
+ {pollutantKeys.map((key) => (
+
+ ))}
+
+
+ )}
+
+ {options.showPollen && hasAvailableValue(airQuality.current.pollen) && (
+
+
+ {t("advanced.pollen")}
+
+
+ {pollenKeys.map((key) => {
+ const pollen = airQuality.current.pollen[key];
+ if (pollen === null) return null;
+ return ;
+ })}
+
+
+ )}
+
+
+
+
+
+ {t("advanced.sevenDayForecast")}
+
+
+ {airQuality.daily.map((day) => {
+ const dailyAqi = standard === "us" ? day.usAqiMax : day.europeanAqiMax;
+ return (
+
+
+ {formatLocalizedDate(day.date, locale, { weekday: "short" })}
+
+
+ {formatAqi(dailyAqi, locale)}
+
+ {options.showUv && (
+ {t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })}
+ )}
+
+ );
+ })}
+
+
+
+
+ );
+};
+
+const MetricCard = ({ label, value }: { label: string; value: string }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+const MetricLine = ({ label, value }: { label: string; value: string }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+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,
+ 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,
+ };
+};
+
+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) =>
+ Object.values(values).some((value) => value !== null);
diff --git a/packages/widgets/src/air-quality/display.spec.ts b/packages/widgets/src/air-quality/display.spec.ts
new file mode 100644
index 000000000..e97243f7d
--- /dev/null
+++ b/packages/widgets/src/air-quality/display.spec.ts
@@ -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 });
+ });
+});
diff --git a/packages/widgets/src/air-quality/display.ts b/packages/widgets/src/air-quality/display.ts
new file mode 100644
index 000000000..08e497af5
--- /dev/null
+++ b/packages/widgets/src/air-quality/display.ts
@@ -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 = (
+ values: Record,
+): { 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);
+};
diff --git a/packages/widgets/src/air-quality/index.ts b/packages/widgets/src/air-quality/index.ts
new file mode 100644
index 000000000..a9dfb28dd
--- /dev/null
+++ b/packages/widgets/src/air-quality/index.ts
@@ -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"));
diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts
index b40a3117c..6ca945d47 100644
--- a/packages/widgets/src/catalog.ts
+++ b/packages/widgets/src/catalog.ts
@@ -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 = {
clock: IconClock,
weather: IconCloud,
+ today: IconCalendarWeek,
+ airQuality: IconWind,
+ countdown: IconHourglass,
+ timer: IconClockPlay,
app: IconApps,
assistant: IconRobot,
iframe: IconBrowser,
diff --git a/packages/widgets/src/clock/advanced-view.tsx b/packages/widgets/src/clock/advanced-view.tsx
new file mode 100644
index 000000000..78ac239af
--- /dev/null
+++ b/packages/widgets/src/clock/advanced-view.tsx
@@ -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 (
+
+
+ {primaryTimeZoneInvalid && (
+ } color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}>
+ {t("worldClock.unsupportedPrimary", { timeZone: options.timezone })}
+
+ )}
+
+
+
+
+ );
+};
+
+interface ModeProps {
+ now: Date;
+ options: WidgetComponentProps<"clock">["options"];
+ primary: WorldClockTime;
+ cities: WorldClockTime[];
+}
+
+const PrimaryClock = ({ now, options, primary }: Pick) => {
+ const locale = useCurrentIntlLocale();
+ const t = useScopedI18n("widget.clock");
+ const primaryTime = primary.zonedTime;
+ if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ {primary.label}
+
+
+
+ {options.showDate && (
+
+ {formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })}
+
+ )}
+
+
+ {primary.timeZone}
+
+
+ {formatUtcOffset(primaryTime.utcOffset())}
+
+
+
+ {options.showWeather && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const OverviewMode = ({ now, options, primary, cities }: ModeProps) => {
+ const t = useScopedI18n("widget.clock");
+ if (cities.length === 0) return ;
+
+ return (
+
+
+
+ {t("worldClock.cities")}
+
+
+ {t("worldClock.cityCount", { count: cities.length })}
+
+
+
+ {cities.map((city) => (
+
+ ))}
+
+
+ );
+};
+
+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 ;
+ }
+
+ return (
+
+
+
+
+ {entry.label}
+
+
+ {entry.timeZone}
+
+
+
+
+
+
+ );
+};
+
+const ClockTime = ({
+ entry,
+ now,
+ options,
+ primary = false,
+ compact = false,
+}: Omit & { 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 (
+
+ {formattedTime}
+
+ );
+};
+
+const DayAndOffset = ({ entry, now, primaryOffset }: Pick) => {
+ 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 (
+
+
+ {date} · {dayRelation}
+
+
+ {formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)}
+
+
+ );
+};
+
+const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => {
+ const t = useScopedI18n("widget.clock");
+ return (
+
+
+
+
+ {entry.label}
+
+ {t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })}
+
+
+
+
+ );
+};
+
+const EmptyCities = () => {
+ const t = useScopedI18n("widget.clock");
+ return (
+
+
+ {t("worldClock.noSecondaryCities")}
+
+
+ );
+};
+
+type ScopedClockTranslator = ReturnType>;
+
+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";
+};
diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx
index 0d17356fe..45180cff9 100644
--- a/packages/widgets/src/clock/component.tsx
+++ b/packages/widgets/src/clock/component.tsx
@@ -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 (
+
+ );
+ }
+ return --:-- ;
+ }
+
+ 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 (
{showWeatherCorner && (
-
+
+
+
)}
{options.customTitleToggle && (
@@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
{options.customTitle}
)}
-
+
- {zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
+ {zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
- {(isAdvanced || options.showDate) && (
+ {options.showDate && (
- {zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
-
- )}
- {isAdvanced && (
-
- {zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
+ {zonedTime?.format(options.dateFormat)}
)}
@@ -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 (
-
- {isInitialWidgetQueryPending(weatherQuery) ? (
-
- ) : (
-
- )}
-
- );
- }
-
- 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 (
-
-
-
-
- {Math.round(temp)}
- {unit}
-
- {showDetails && }
-
- {showDetails && (
-
-
- {locationName}
-
-
- {formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}%
-
-
- {Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "}
- {isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")}
-
-
- {t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "}
- {t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)}
-
-
- )}
-
-
- );
-};
-
-const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—";
-
-interface UseCurrentTimeProps {
- showSeconds: boolean;
-}
-
-const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => {
- const [time, setTime] = useState(null);
- const timeoutRef = useRef(null);
- const intervalRef = useRef(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;
};
diff --git a/packages/widgets/src/clock/format.ts b/packages/widgets/src/clock/format.ts
new file mode 100644
index 000000000..934bc818d
--- /dev/null
+++ b/packages/widgets/src/clock/format.ts
@@ -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);
+};
diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts
index e7657b918..8f3fdd06f 100644
--- a/packages/widgets/src/clock/index.ts
+++ b/packages/widgets/src/clock/index.ts
@@ -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,
+ },
},
);
},
diff --git a/packages/widgets/src/clock/time-of-day-bar.tsx b/packages/widgets/src/clock/time-of-day-bar.tsx
new file mode 100644
index 000000000..9f4ba5dce
--- /dev/null
+++ b/packages/widgets/src/clock/time-of-day-bar.tsx
@@ -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 (
+
+
+
+ {t(`worldClock.phase.${phase}`)}
+
+
+ {t("worldClock.dayProgress", {
+ percent: Math.round(progress),
+ hours: remainingHours,
+ minutes: remainder.toString().padStart(2, "0"),
+ })}
+
+
+
+
+ {phaseSections.map((section) => (
+
+ ))}
+
+
+
+ {showTicks && (
+
+ {["00", "06", "12", "18", "24"].map((tick) => (
+
+ {tick}
+
+ ))}
+
+ )}
+ {showLegend && (
+
+
+ {t("worldClock.phaseLegend")}
+
+ {phaseLegend.map((item) => (
+
+
+
+ {t(`worldClock.phase.${item.key}`)} · {item.range}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+const getPhaseColor = (phase: TimeOfDayPhase) => {
+ if (phase === "night") return "indigo";
+ if (phase === "dawn") return "orange";
+ if (phase === "day") return "cyan";
+ return "grape";
+};
diff --git a/packages/widgets/src/clock/use-current-time.ts b/packages/widgets/src/clock/use-current-time.ts
new file mode 100644
index 000000000..6d7b30a6f
--- /dev/null
+++ b/packages/widgets/src/clock/use-current-time.ts
@@ -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(null);
+ const timeoutRef = useRef>(null);
+ const intervalRef = useRef>(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;
+};
diff --git a/packages/widgets/src/clock/weather-summary.tsx b/packages/widgets/src/clock/weather-summary.tsx
new file mode 100644
index 000000000..bb187a7bd
--- /dev/null
+++ b/packages/widgets/src/clock/weather-summary.tsx
@@ -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) ? (
+
+ ) : (
+
+ );
+ }
+
+ 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 (
+
+
+
+
+ {Math.round(temperature)}
+ {unit}
+
+ {detailed && }
+
+ {detailed && (
+
+
+ {locationName}
+
+
+ {formatTemperature(today?.maxTemperature)} / {formatTemperature(today?.minTemperature)}
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/widgets/src/clock/world-clock.spec.ts b/packages/widgets/src/clock/world-clock.spec.ts
new file mode 100644
index 000000000..47b134a43
--- /dev/null
+++ b/packages/widgets/src/clock/world-clock.spec.ts
@@ -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();
+ });
+});
diff --git a/packages/widgets/src/clock/world-clock.ts b/packages/widgets/src/clock/world-clock.ts
new file mode 100644
index 000000000..e951fa916
--- /dev/null
+++ b/packages/widgets/src/clock/world-clock.ts
@@ -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;
diff --git a/packages/widgets/src/common/browser-alert.spec.ts b/packages/widgets/src/common/browser-alert.spec.ts
new file mode 100644
index 000000000..9538e001c
--- /dev/null
+++ b/packages/widgets/src/common/browser-alert.spec.ts
@@ -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");
+ });
+});
diff --git a/packages/widgets/src/common/browser-alert.ts b/packages/widgets/src/common/browser-alert.ts
new file mode 100644
index 000000000..e4bb54389
--- /dev/null
+++ b/packages/widgets/src/common/browser-alert.ts
@@ -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 => {
+ 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;
+ }
+};
diff --git a/packages/widgets/src/common/use-local-widget-state.spec.ts b/packages/widgets/src/common/use-local-widget-state.spec.ts
new file mode 100644
index 000000000..c3ddad228
--- /dev/null
+++ b/packages/widgets/src/common/use-local-widget-state.spec.ts
@@ -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 });
+ });
+});
diff --git a/packages/widgets/src/common/use-local-widget-state.ts b/packages/widgets/src/common/use-local-widget-state.ts
new file mode 100644
index 000000000..c32d30278
--- /dev/null
+++ b/packages/widgets/src/common/use-local-widget-state.ts
@@ -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 {
+ key: string | undefined;
+ version: number;
+ defaultValue: T | (() => T);
+ validate?: (value: unknown) => value is T;
+ migrate?: (value: unknown, storedVersion: number) => T | undefined;
+}
+
+const resolveDefaultValue = (value: T | (() => T)): T =>
+ typeof value === "function" ? (value as () => T)() : value;
+
+export const serializeLocalWidgetState = (version: number, value: T): string => JSON.stringify({ version, value });
+
+export const parseLocalWidgetState = (
+ 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 = ({
+ key,
+ version,
+ defaultValue,
+ validate,
+ migrate,
+}: UseLocalWidgetStateOptions): [T, Dispatch>, () => 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(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> = 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];
+};
diff --git a/packages/widgets/src/common/use-widget-now.spec.ts b/packages/widgets/src/common/use-widget-now.spec.ts
new file mode 100644
index 000000000..8c7c81a56
--- /dev/null
+++ b/packages/widgets/src/common/use-widget-now.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/widgets/src/common/use-widget-now.ts b/packages/widgets/src/common/use-widget-now.ts
new file mode 100644
index 000000000..40f7c88cd
--- /dev/null
+++ b/packages/widgets/src/common/use-widget-now.ts
@@ -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(null);
+ const timeoutRef = useRef>(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;
+};
diff --git a/packages/widgets/src/countdown/component.module.css b/packages/widgets/src/countdown/component.module.css
new file mode 100644
index 000000000..21b9d50f1
--- /dev/null
+++ b/packages/widgets/src/countdown/component.module.css
@@ -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;
+ }
+}
diff --git a/packages/widgets/src/countdown/component.tsx b/packages/widgets/src/countdown/component.tsx
new file mode 100644
index 000000000..9f97185c5
--- /dev/null
+++ b/packages/widgets/src/countdown/component.tsx
@@ -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({
+ key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined,
+ version: 2,
+ defaultValue: defaultAlertPreferences,
+ validate: isAlertPreferences,
+ migrate: migrateAlertPreferences,
+ });
+ const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission());
+ const previousNowRef = useRef(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 (
+
+ —
+
+ );
+ }
+
+ const hero = states[0];
+ if (!hero) {
+ return (
+
+
+
+
+ {t("empty.title")}
+
+ {t("empty.description")}
+
+
+ );
+ }
+
+ let heroStatus: string | null = null;
+ if (hero.invalid) heroStatus = t("invalid.value");
+ else if (hero.completed) heroStatus = t("completed");
+
+ if (displayMode !== "advanced") {
+ return (
+
+
+ {heroStatus && (
+
+ {heroStatus}
+
+ )}
+
+ {hero.event.label}
+
+
+
+
+ {formatOccurrence(hero, locale)}
+
+ {options.showProgress && (
+
+ )}
+
+ );
+ }
+
+ 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 (
+
+
+
+
+ {heroStatus && {heroStatus} }
+
+ {formatOccurrence(hero, locale)}
+
+
+
+ {hero.event.label}
+
+
+
+ {options.showProgress && }
+
+
+
+
+
+
+
+
+ {grouped.invalid.length > 0 && (
+
+ {t("invalid.description", { count: grouped.invalid.length })}
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+ {value}
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ {title}
+
+ {states.map((state) => (
+
+
+
+
+ {state.event.label}
+
+ {state.event.recurrence === "yearly" && {t("yearly")} }
+
+
+
+ {showProgress && }
+
+ {formatOccurrence(state, locale)}
+
+
+
+
+ ))}
+
+
+ );
+};
+
+const AlertControls = ({
+ disabled,
+ persistent,
+ preferences,
+ notificationPermission,
+ onNotificationsChange,
+}: {
+ disabled: boolean;
+ persistent: boolean;
+ preferences: AlertPreferences;
+ notificationPermission: ReturnType;
+ onNotificationsChange: (enabled: boolean) => Promise;
+}) => {
+ const t = useScopedI18n("widget.countdown.alert");
+ if (!persistent) return null;
+ return (
+
+ void onNotificationsChange(event.currentTarget.checked)}
+ label={t("notifications")}
+ thumbIcon={preferences.notifications ? : }
+ />
+ {notificationPermission === "denied" && (
+
+ {t("permissionDenied")}
+
+ )}
+
+ );
+};
+
+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);
+};
diff --git a/packages/widgets/src/countdown/display.ts b/packages/widgets/src/countdown/display.ts
new file mode 100644
index 000000000..2e7c649f7
--- /dev/null
+++ b/packages/widgets/src/countdown/display.ts
@@ -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(" ");
+};
diff --git a/packages/widgets/src/countdown/index.ts b/packages/widgets/src/countdown/index.ts
new file mode 100644
index 000000000..204a5ec26
--- /dev/null
+++ b/packages/widgets/src/countdown/index.ts
@@ -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"));
diff --git a/packages/widgets/src/countdown/recurrence.spec.ts b/packages/widgets/src/countdown/recurrence.spec.ts
new file mode 100644
index 000000000..1d039379c
--- /dev/null
+++ b/packages/widgets/src/countdown/recurrence.spec.ts
@@ -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 => ({
+ 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);
+ });
+});
diff --git a/packages/widgets/src/countdown/recurrence.ts b/packages/widgets/src/countdown/recurrence.ts
new file mode 100644
index 000000000..8d161b05e
--- /dev/null
+++ b/packages/widgets/src/countdown/recurrence.ts
@@ -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();
+ 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;
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index d6565a702..060185a39 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -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,
diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts
index 2cb9a373c..c8a1f319f 100644
--- a/packages/widgets/src/manifest.spec.ts
+++ b/packages/widgets/src/manifest.spec.ts
@@ -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(),
);
});
diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts
index 3e12ea75b..9d1cb6e6d 100644
--- a/packages/widgets/src/manifest.ts
+++ b/packages/widgets/src/manifest.ts
@@ -24,6 +24,10 @@ export type WidgetResources = {
const moduleLoaders: Record Promise> = {
clock: () => import("./clock") as Promise,
weather: () => import("./weather") as Promise,
+ today: () => import("./today") as Promise,
+ airQuality: () => import("./air-quality") as Promise,
+ countdown: () => import("./countdown") as Promise,
+ timer: () => import("./timer") as Promise,
app: () => import("./app") as Promise,
assistant: () => import("./assistant") as Promise,
archiveTeamWarrior: () => import("./archive-team-warrior") as Promise,
@@ -85,6 +89,10 @@ const moduleLoaders: Record Promise> = {
const componentLoaders: Record Promise> = {
clock: () => import("./clock/component") as Promise,
weather: () => import("./weather/component") as Promise,
+ today: () => import("./today/component") as Promise,
+ airQuality: () => import("./air-quality/component") as Promise,
+ countdown: () => import("./countdown/component") as Promise,
+ timer: () => import("./timer/component") as Promise,
app: () => import("./app/component") as Promise,
assistant: () => import("./assistant/component") as Promise,
archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise,
diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts
index 80e0113c5..74e20a83c 100644
--- a/packages/widgets/src/options.ts
+++ b/packages/widgets/src/options.ts
@@ -52,6 +52,7 @@ interface SelectInput extends CommonIn
> {
options: TOptions;
searchable?: boolean;
+ withPlaceholder?: boolean;
}
interface DynamicSelectInput extends CommonInput {
@@ -107,6 +108,34 @@ interface SliderInput extends CommonInput {
step?: number;
}
+export interface OptionTimezone {
+ id: string;
+ label: string;
+ timeZone: string;
+}
+
+interface TimezoneListInput extends CommonInput {
+ 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 {
+ 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();
+ const timeZones = new Set();
+
+ 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();
+ 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 & { validate?: ZodType }) => ({
type: "multiText" as const,
defaultValue: input?.defaultValue ?? [],
diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts
index d6a5160cc..7f60c3154 100644
--- a/packages/widgets/src/refetch-intervals.ts
+++ b/packages/widgets/src/refetch-intervals.ts
@@ -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 },
diff --git a/packages/widgets/src/timer/component.module.css b/packages/widgets/src/timer/component.module.css
new file mode 100644
index 000000000..0f1d68fea
--- /dev/null
+++ b/packages/widgets/src/timer/component.module.css
@@ -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;
+ }
+}
diff --git a/packages/widgets/src/timer/component.tsx b/packages/widgets/src/timer/component.tsx
new file mode 100644
index 000000000..482519fab
--- /dev/null
+++ b/packages/widgets/src/timer/component.tsx
@@ -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(
+ () => ({
+ 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({
+ 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(null);
+ const completionSoundTimeoutRef = useRef(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 (
+
+ —
+
+ );
+ }
+
+ 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 ;
+
+ return (
+
+ );
+}
+
+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 (
+
+
+
+ {phaseLabel}
+
+ {runtime.mode === "pomodoro" && (
+
+ {t("compact.completedFocus", { count: runtime.completedFocusSessions })}
+
+ )}
+
+
+ {formatTimerDuration(remainingMs)}
+
+
+
+
+ );
+};
+
+interface AdvancedTimerProps extends TimerDisplayProps {
+ durations: TimerDurations;
+ notificationPermission: ReturnType;
+ onNotificationsChange: (enabled: boolean) => Promise;
+ 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 (
+
+
+
+
+
+ {phaseLabel}
+
+
+ {formatTimerDuration(remainingMs)}
+
+
+
+
+
+
+ {runtime.mode === "pomodoro" && }
+
+
+
+
+
+ {t("advanced.settings")}
+
+
+
+ {t("advanced.settingsHint")}
+
+
+
+
+
+
+ {t("advanced.localAlerts")}
+
+
+ void onNotificationsChange(event.currentTarget.checked)}
+ />
+ onSoundChange(event.currentTarget.checked)}
+ thumbIcon={ }
+ />
+
+ {t("advanced.localAlertsHint")}
+
+ {notificationPermission === "denied" && {t("advanced.notificationsDenied")} }
+
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+ {isRunning ? : }
+
+
+
+
+
+
+
+
+ );
+};
+
+type ScopedTimerTranslator = ReturnType>;
+
+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 (
+
+
+ {t("advanced.sequence")}
+
+
+ {Array.from({ length: durations.sessionsBeforeLongBreak }, (_, index) => (
+
+
+ {t("advanced.focusSession", { session: index + 1 })}
+
+
+ {t("phase.shortBreak")}
+
+
+ ))}
+
+ {t("phase.longBreak")}
+
+
+
+ );
+};
+
+const SettingsSummary = ({ mode, durations }: { mode: TimerRuntimeState["mode"]; durations: TimerDurations }) => {
+ const t = useScopedI18n("widget.timer");
+ if (mode === "timer") return ;
+ return (
+
+
+
+
+
+
+ );
+};
+
+const SettingLine = ({ label, value }: { label: string; value: number }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+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}`;
+};
diff --git a/packages/widgets/src/timer/index.ts b/packages/widgets/src/timer/index.ts
new file mode 100644
index 000000000..c5fbf26fb
--- /dev/null
+++ b/packages/widgets/src/timer/index.ts
@@ -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"));
diff --git a/packages/widgets/src/timer/runtime.spec.ts b/packages/widgets/src/timer/runtime.spec.ts
new file mode 100644
index 000000000..bba01ea1a
--- /dev/null
+++ b/packages/widgets/src/timer/runtime.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/widgets/src/timer/runtime.ts b/packages/widgets/src/timer/runtime.ts
new file mode 100644
index 000000000..2189780e0
--- /dev/null
+++ b/packages/widgets/src/timer/runtime.ts
@@ -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 =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx
new file mode 100644
index 000000000..b98158324
--- /dev/null
+++ b/packages/widgets/src/today/component.tsx
@@ -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 (
+
+ —
+
+ );
+ }
+
+ const stats = getTodayStats(now, options.weekConvention, locale);
+ if (displayMode === "advanced") {
+ return ;
+ }
+
+ 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 (
+
+
+ {weekday} · {month}
+
+
+ {now.getDate()}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {weekday}
+
+
+ {layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
+
+
+ {options.showYearProgress && (
+
+
+ {formatProgress(stats.yearProgress)}%
+
+ }
+ />
+
+
+ {t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
+
+
+
+ )}
+
+
+ {layout.showStats && (
+
+ {options.showWeekNumber && {t("weekNumber", { week: stats.weekNumber })} }
+ {options.showDayOfYear && (
+ {t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}
+ )}
+ {layout.showQuarter && options.showQuarter && (
+ {t("quarter", { quarter: stats.quarter })}
+ )}
+ {layout.showQuarter && (
+
+ {t("daysAfterToday", { days: stats.daysAfterToday })}
+
+ )}
+
+ )}
+
+ {layout.showMonthProgress && }
+
+ );
+}
+
+interface AdvancedTodayProps {
+ now: Date;
+ locale: string;
+ options: WidgetComponentProps<"today">["options"];
+ stats: ReturnType;
+}
+
+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 (
+
+
+
+
+ {formatLocalizedDate(now, locale, { dateStyle: "full" })}
+
+ {options.showWeekNumber && (
+
+ {t(`weekConvention.${options.weekConvention}`)}
+
+ )}
+
+
+
+ {visibleMetrics.map((metric) => (
+
+
+ {metric.label}
+
+
+ {metric.value}
+
+
+ ))}
+
+
+
+
+
+ {options.showYearProgress && }
+
+
+
+ {Array.from({ length: 12 }, (_, monthIndex) => {
+ const date = new Date(now.getFullYear(), monthIndex, 1);
+ const isCurrent = monthIndex === stats.monthIndex;
+ return (
+
+
+ {formatLocalizedDate(date, locale, { month: "short" })}
+
+ {isCurrent && (
+
+ {now.getDate()}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
+
+const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
+ const roundedValue = formatProgress(value);
+ return (
+
+
+
+ {label}
+
+
+ {roundedValue}%
+
+
+
+
+ );
+};
+
+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}`;
+};
diff --git a/packages/widgets/src/today/date-stats.spec.ts b/packages/widgets/src/today/date-stats.spec.ts
new file mode 100644
index 000000000..414732dfa
--- /dev/null
+++ b/packages/widgets/src/today/date-stats.spec.ts
@@ -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,
+ });
+ });
+});
diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts
new file mode 100644
index 000000000..da9313775
--- /dev/null
+++ b/packages/widgets/src/today/date-stats.ts
@@ -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,
+ };
+};
diff --git a/packages/widgets/src/today/index.ts b/packages/widgets/src/today/index.ts
new file mode 100644
index 000000000..e494df35b
--- /dev/null
+++ b/packages/widgets/src/today/index.ts
@@ -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"));
diff --git a/packages/widgets/src/weather/advanced.tsx b/packages/widgets/src/weather/advanced.tsx
new file mode 100644
index 000000000..d60eea81a
--- /dev/null
+++ b/packages/widgets/src/weather/advanced.tsx
@@ -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 (
+
+
+ {datum.tooltipLabel}
+
+
+ {datum.temperatureSummary} · {datum.precipitationSummary}
+
+ {datum.detailLabel}
+
+ );
+};
+
+const MetricCard = ({ icon, label, value }: MetricCardProps) => (
+
+
+ {icon}
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+);
+
+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((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 (
+
+
+
+
+
+
+
+ {getPreferredUnit(
+ weather.current.temperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ )}
+
+
+
+ {t("advanced.feelsLike", {
+ temperature: getPreferredUnit(
+ weather.current.apparentTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ ),
+ })}
+
+
+
+
+
+
+
+ {options.location.name}
+
+
+
+ {t("advanced.observedAt", {
+ time: getPreferredTime(weather.current.observedAt, locale, weather.timezone),
+ })}
+
+ {today && (
+
+ {t("advanced.highLow", {
+ maximum: getPreferredUnit(
+ today.maxTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ ),
+ minimum: getPreferredUnit(
+ today.minTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ ),
+ })}
+
+ )}
+
+
+
+
+ }
+ label={t("advanced.nextHourPrecipitation")}
+ value={t("advanced.precipitationValue", {
+ amount: nextHour?.precipitation?.toFixed(1) ?? "?",
+ probability: nextHour?.precipitationProbability ?? "?",
+ })}
+ />
+ {options.showHumidity && (
+ }
+ label={t("advanced.currentHumidity")}
+ value={`${weather.current.relativeHumidity}%`}
+ />
+ )}
+ }
+ label={t("advanced.currentWind")}
+ value={t("advanced.windValue", {
+ gusts: getPreferredWindSpeed(weather.current.windGusts, options.useImperialSpeed),
+ speed: getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed),
+ unit: speedUnit,
+ })}
+ />
+ }
+ label={t("advanced.todayUv")}
+ value={today?.uvIndex?.toFixed(1) ?? "?"}
+ />
+ }
+ label={t("dailyForecast.sunrise")}
+ value={getPreferredTime(today?.sunriseAt, locale, weather.timezone)}
+ />
+ }
+ label={t("dailyForecast.sunset")}
+ value={getPreferredTime(today?.sunsetAt, locale, weather.timezone)}
+ />
+
+
+ {chartData.length > 0 && (
+
+
+ {t("advanced.hourlyForecast")}
+
+
+ `${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: }}
+ />
+
+
+
+
+
+ )}
+
+
+ {t("advanced.forecast")}
+
+
+ {weather.daily.map((day) => (
+
+
+
+
+
+
+
+
+ {getPreferredDate(day.date, locale, { weekday: "short" })}
+
+
+ {getPreferredDate(day.date, locale, { month: "short", day: "numeric" })}
+
+
+
+
+
+
+ {t(`kind.${getWeatherKind(day.weatherCode)}`)}
+
+
+
+
+ {getPreferredUnit(
+ day.maxTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ )}
+
+
+ {getPreferredUnit(
+ day.minTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ )}
+
+
+
+
+
+
+
+ {day.precipitationProbability ?? "?"}%
+
+
+
+
+
+ {getPreferredWindSpeed(day.maxWindSpeed, options.useImperialSpeed)} {speedUnit}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+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 (
+
+ {t("advanced.hourlyForecast")}
+
+
+ {t("advanced.time")}
+ {t("advanced.conditions")}
+ {t("advanced.temperature")}
+ {t("advanced.feelsLikeLabel")}
+ {t("advanced.precipitationChance")}
+ {showHumidity && {t("advanced.humidity")} }
+ {t("advanced.wind", { unit: speedUnit })}
+
+
+
+ {hourly.map((hour) => (
+
+ {getPreferredTime(hour.observedAt, locale, timeZone)}
+ {hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`)}
+ {hour.temperature === null ? "?" : `${hour.temperature.toFixed(1)}${temperatureUnit}`}
+
+ {hour.apparentTemperature === null ? "?" : `${hour.apparentTemperature.toFixed(1)}${temperatureUnit}`}
+
+ {hour.precipitationProbability === null ? "?" : `${hour.precipitationProbability}%`}
+ {showHumidity && {hour.relativeHumidity === null ? "?" : `${hour.relativeHumidity}%`} }
+
+ {getPreferredWindSpeed(hour.windSpeed, useImperialSpeed)} /{" "}
+ {getPreferredWindSpeed(hour.windGusts, useImperialSpeed)} {speedUnit}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/widgets/src/weather/animated-icon.tsx b/packages/widgets/src/weather/animated-icon.tsx
index e95fa2f47..cbe2e0949 100644
--- a/packages/widgets/src/weather/animated-icon.tsx
+++ b/packages/widgets/src/weather/animated-icon.tsx
@@ -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 (
-
-
+
+
);
};
diff --git a/packages/widgets/src/weather/compact.tsx b/packages/widgets/src/weather/compact.tsx
new file mode 100644
index 000000000..f2cb7e5ad
--- /dev/null
+++ b/packages/widgets/src/weather/compact.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getPreferredUnit(
+ weather.current.temperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ )}
+
+ {layout.showCondition && }
+
+
+
+ {layout.showHighLow && today && (
+
+
+
+
+ {getPreferredUnit(today.maxTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
+
+
+
+
+
+ {getPreferredUnit(today.minTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
+
+
+
+ )}
+
+ {layout.showSecondary && !options.hasForecast && (
+
+ {options.showHumidity && (
+
+
+ {weather.current.relativeHumidity}%
+
+ )}
+ {options.showCurrentWindSpeed && (
+
+
+
+ {getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed)} {speedUnit}
+
+
+ )}
+
+ )}
+
+ {layout.forecastDays > 0 && (
+
+ {weather.daily.slice(0, layout.forecastDays).map((day, index) => (
+
+
+
+
+
+ {getPreferredDate(day.date, locale, { weekday: "short" })}
+
+
+
+ {getPreferredUnit(
+ day.maxTemperature,
+ options.isFormatFahrenheit,
+ options.disableTemperatureDecimals,
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {layout.showCity && options.showCity && (
+
+
+
+ {options.location.name}
+
+
+ )}
+
+ );
+};
diff --git a/packages/widgets/src/weather/component.module.css b/packages/widgets/src/weather/component.module.css
index 4b7b623be..df523cd81 100644
--- a/packages/widgets/src/weather/component.module.css
+++ b/packages/widgets/src/weather/component.module.css
@@ -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;
+ }
+}
diff --git a/packages/widgets/src/weather/component.spec.ts b/packages/widgets/src/weather/component.spec.ts
index fc0d219a2..b60dbe4fb 100644
--- a/packages/widgets/src/weather/component.spec.ts
+++ b/packages/widgets/src/weather/component.spec.ts
@@ -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,
+ });
+ });
+});
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index 357318fa6..f30077865 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -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 ;
if (!weather) return ;
- const content =
- displayMode === "advanced" ? (
-
- ) : (
-
- {options.hasForecast ? (
-
- ) : (
-
- )}
-
- );
-
return (
-
- {content}
+
+
+ {displayMode === "advanced" ? (
+
+ ) : (
+
+ )}
+
{weatherQuery.error && (
-
+
)}
);
}
-
-interface WeatherProps extends Pick, "options"> {
- weather: NonNullable;
-}
-
-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 (
-
-
-
-
-
-
- {getPreferredUnit(
- weather.current.temperature,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
-
-
-
-
-
- {options.location.name}
-
-
-
-
- {t("currentWindSpeed", {
- currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
- unit: options.useImperialSpeed
- ? tCommon("unit.speed.milesPerHour")
- : tCommon("unit.speed.kilometersPerHour"),
- })}
-
-
-
-
-
-
- {t("advanced.forecast")}
-
-
-
-
-
- {t("advanced.day")}
- {t("advanced.conditions")}
- {t("advanced.temperature")}
- {t("advanced.humidity")}
-
- {t("advanced.wind", {
- unit: options.useImperialSpeed
- ? tCommon("unit.speed.milesPerHour")
- : tCommon("unit.speed.kilometersPerHour"),
- })}
-
- {t("advanced.sun")}
-
-
-
- {weather.daily.length === 0 ? (
-
-
-
- {t("advanced.noForecast")}
-
-
-
- ) : (
- weather.daily.map((dayWeather) => (
-
-
-
-
- {formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })}
-
-
- {formatLocalizedDate(dayWeather.time, locale, { month: "short", day: "numeric" })}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {getPreferredUnit(
- dayWeather.maxTemp,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
- /
-
-
-
- {getPreferredUnit(
- dayWeather.minTemp,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
-
-
-
- {dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}
-
-
-
-
-
-
- {getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
- {getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
-
-
-
-
-
-
-
- {getPreferredTime(dayWeather.sunrise, locale)}
-
-
- /
-
-
-
- {getPreferredTime(dayWeather.sunset, locale)}
-
-
-
-
- ))
- )}
-
-
-
-
- );
-};
-
-const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) => {
- const t = useScopedI18n("widget.weather");
- const tCommon = useScopedI18n("common");
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- {getPreferredUnit(
- weather.current.temperature,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
- {options.showCurrentWindSpeed && (
-
-
-
- {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"),
- })}
-
-
- )}
- {weather.daily[0]?.humidity !== undefined && (
-
-
- {t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}
-
- )}
-
-
-
-
- {getPreferredUnit(
- weather.daily[0]?.maxTemp,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
-
-
- {getPreferredUnit(
- weather.daily[0]?.minTemp,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
-
- {options.showCity && (
- <>
-
-
-
- {options.location.name}
-
-
- >
- )}
- >
- );
-};
-
-const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) => {
- const t = useScopedI18n("widget.weather");
-
- return (
- <>
-
- {options.showCity && (
-
-
-
- {options.location.name}
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {getPreferredUnit(
- weather.current.temperature,
- options.isFormatFahrenheit,
- options.disableTemperatureDecimals,
- )}
-
-
-
-
- >
- );
-};
-
-function Forecast({ weather, options, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) {
- const dateFormat = options.dateFormat;
- const t = useScopedI18n("widget.weather");
- const locale = useCurrentIntlLocale();
- return (
-
- {weather.daily.slice(0, maxDays).map((dayWeather, index) => (
-
-
-
-
-
- {formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })}
-
-
-
- {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
-
-
-
-
-
-
-
-
- ))}
-
- );
-}
-
-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);
-};
diff --git a/packages/widgets/src/weather/details.tsx b/packages/widgets/src/weather/details.tsx
new file mode 100644
index 000000000..6316de5dd
--- /dev/null
+++ b/packages/widgets/src/weather/details.tsx
@@ -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) => (
+
+
+
+ {icon}
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+);
+
+const SolarMetric = ({ icon, label, value }: DetailMetricProps) => (
+
+
+ {icon}
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+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 (
+
+
+
+
+
+
+
+
+ {formatWeatherDate(day.date, locale, dateFormat)}
+
+
+ {t(`kind.${getWeatherKind(day.weatherCode)}`)}
+
+
+
+
+ {t("advanced.highLow", {
+ maximum: getPreferredUnit(day.maxTemperature, isFahrenheit, disableTemperatureDecimals),
+ minimum: getPreferredUnit(day.minTemperature, isFahrenheit, disableTemperatureDecimals),
+ })}
+
+
+
+
+ }
+ label={t("advanced.detailLabel.feelsLike")}
+ value={`${getPreferredUnit(day.maxApparentTemperature, isFahrenheit, disableTemperatureDecimals)} / ${getPreferredUnit(day.minApparentTemperature, isFahrenheit, disableTemperatureDecimals)}`}
+ />
+ }
+ label={t("advanced.detailLabel.precipitation")}
+ value={`${day.precipitationProbability ?? "?"}% · ${day.precipitation?.toFixed(1) ?? "?"} mm`}
+ />
+ {showHumidity && (
+ }
+ label={t("advanced.detailLabel.humidity")}
+ value={`${day.averageHumidity ?? "?"}%`}
+ />
+ )}
+ }
+ label={t("advanced.detailLabel.wind")}
+ value={`${getPreferredWindSpeed(day.maxWindSpeed, useImperialSpeed)} / ${getPreferredWindSpeed(day.maxWindGusts, useImperialSpeed)} ${speedUnit}`}
+ />
+ }
+ label={t("advanced.detailLabel.uv")}
+ value={day.uvIndex?.toFixed(1) ?? "?"}
+ />
+
+
+
+
+ }
+ label={t("dailyForecast.sunrise")}
+ value={getPreferredTime(day.sunriseAt, locale, timeZone)}
+ />
+ }
+ label={t("dailyForecast.sunset")}
+ value={getPreferredTime(day.sunsetAt, locale, timeZone)}
+ />
+ }
+ label={t("advanced.detailLabel.daylight")}
+ value={getPreferredDaylightDuration(day.daylightDuration)}
+ />
+
+
+
+ );
+};
diff --git a/packages/widgets/src/weather/format.ts b/packages/widgets/src/weather/format.ts
new file mode 100644
index 000000000..a65c213ce
--- /dev/null
+++ b/packages/widgets/src/weather/format.ts
@@ -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;
+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),
+ }));
diff --git a/packages/widgets/src/weather/icon.tsx b/packages/widgets/src/weather/icon.tsx
index 50268b1c1..3eeb3696b 100644
--- a/packages/widgets/src/weather/icon.tsx
+++ b/packages/widgets/src/weather/icon.tsx
@@ -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 ;
const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
return ;
};
+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 {t(`kind.${name}`)} ;
@@ -95,12 +100,22 @@ export const WeatherDescription = ({
{formatWeatherDate(time, locale, dateFormat)}
{t(`kind.${name}`)}
- }>{`${tCommon("information.max")}: ${maxTemp}`}
- }>{`${tCommon("information.min")}: ${minTemp}`}
- }>{`${t("dailyForecast.sunrise")}: ${sunrise}`}
- }>{`${t("dailyForecast.sunset")}: ${sunset}`}
+ }
+ >{`${tCommon("information.max")}: ${maxTemp}`}
+ }
+ >{`${tCommon("information.min")}: ${minTemp}`}
+ }
+ >{`${t("dailyForecast.sunrise")}: ${sunrise}`}
+ }
+ >{`${t("dailyForecast.sunset")}: ${sunset}`}
{humidity !== undefined && (
- }>{t("dailyForecast.humidity", { humidity })}
+ }>
+ {t("dailyForecast.humidity", { humidity })}
+
)}
{maxWindSpeed !== undefined && (
}>
diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts
index 11ced3b39..73a7cf40a 100644
--- a/packages/widgets/src/weather/index.ts
+++ b/packages/widgets/src/weather/index.ts
@@ -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,
diff --git a/packages/widgets/src/weather/layout.ts b/packages/widgets/src/weather/layout.ts
new file mode 100644
index 000000000..bf647b7af
--- /dev/null
+++ b/packages/widgets/src/weather/layout.ts
@@ -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,
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a17aa075..1cc190710 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2910,6 +2910,9 @@ importers:
'@mantine/hooks':
specifier: 'catalog:'
version: 9.4.1(react@19.2.7)
+ '@rexa-developer/tiks':
+ specifier: 'catalog:'
+ version: 0.3.0(react@19.2.7)(vue@3.5.35(typescript@7.0.2))
'@tabler/icons-react':
specifier: 'catalog:'
version: 3.44.0(react@19.2.7)