Compare commits

...
86 changed files with 7660 additions and 751 deletions

No files matched your search

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