Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a138e8d8b7 | ||
|
|
18bfb26b91 | ||
|
|
16df23712e | ||
|
|
4f1599dfee | ||
|
|
e3475d3efe | ||
|
|
52d96505a3 | ||
|
|
56c2c4dcc0 |
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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`
|
||||
@@ -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
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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¤t_weather=true&timezone=auto`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
const json: unknown = await res.json();
|
||||
const weather = await atLocationOutput.parseAsync(json);
|
||||
return {
|
||||
current: weather.current_weather,
|
||||
daily: weather.daily.time.map((value, index) => {
|
||||
return {
|
||||
time: value,
|
||||
weatherCode: weather.daily.weathercode[index] ?? 404,
|
||||
maxTemp: weather.daily.temperature_2m_max[index],
|
||||
minTemp: weather.daily.temperature_2m_min[index],
|
||||
sunrise: weather.daily.sunrise[index],
|
||||
sunset: weather.daily.sunset[index],
|
||||
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
|
||||
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
|
||||
humidity: weather.daily.relative_humidity_2m_mean[index],
|
||||
};
|
||||
}),
|
||||
} satisfies Weather;
|
||||
},
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
const nullableNumberArray = z.array(z.number().nullable()).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;
|
||||
}[];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"));
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(" ");
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -52,6 +52,7 @@ interface SelectInput<TOptions extends readonly SelectOption[]> extends CommonIn
|
||||
> {
|
||||
options: TOptions;
|
||||
searchable?: boolean;
|
||||
withPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||
@@ -107,6 +108,34 @@ interface SliderInput extends CommonInput<number> {
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface OptionTimezone {
|
||||
id: string;
|
||||
label: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
interface TimezoneListInput extends CommonInput<OptionTimezone[]> {
|
||||
maxValues?: number;
|
||||
presets?: readonly OptionTimezone[];
|
||||
timeZoneOptions: readonly { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export type DateTimeEventRecurrence = "none" | "yearly";
|
||||
|
||||
export interface OptionDateTimeEvent {
|
||||
id: string;
|
||||
label: string;
|
||||
targetUtc: string;
|
||||
timeZone: string;
|
||||
startUtc?: string;
|
||||
recurrence: DateTimeEventRecurrence;
|
||||
}
|
||||
|
||||
interface DateTimeEventListInput extends CommonInput<OptionDateTimeEvent[]> {
|
||||
maxValues?: number;
|
||||
timeZoneOptions: readonly { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface OptionLocation {
|
||||
name: string;
|
||||
latitude: number;
|
||||
@@ -163,6 +192,7 @@ const optionsFactory = {
|
||||
options: input.options,
|
||||
searchable: input.searchable ?? false,
|
||||
withDescription: input.withDescription ?? false,
|
||||
withPlaceholder: input.withPlaceholder ?? false,
|
||||
}),
|
||||
number: (input: NumberInput) => ({
|
||||
type: "number" as const,
|
||||
@@ -192,6 +222,72 @@ const optionsFactory = {
|
||||
longitude: z.number(),
|
||||
}),
|
||||
}),
|
||||
timezoneList: (input: TimezoneListInput) => {
|
||||
const maxValues = input.maxValues ?? 6;
|
||||
return {
|
||||
type: "timezoneList" as const,
|
||||
defaultValue: input.defaultValue ?? [],
|
||||
maxValues,
|
||||
presets: input.presets ?? [],
|
||||
timeZoneOptions: input.timeZoneOptions,
|
||||
withDescription: input.withDescription ?? false,
|
||||
validate: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().trim().min(1).max(64),
|
||||
timeZone: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.max(maxValues)
|
||||
.superRefine((values, ctx) => {
|
||||
const ids = new Set<string>();
|
||||
const timeZones = new Set<string>();
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (ids.has(value.id)) {
|
||||
ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" });
|
||||
}
|
||||
if (timeZones.has(value.timeZone)) {
|
||||
ctx.addIssue({ code: "custom", path: [index, "timeZone"], message: "Duplicate timezone" });
|
||||
}
|
||||
ids.add(value.id);
|
||||
timeZones.add(value.timeZone);
|
||||
});
|
||||
}),
|
||||
};
|
||||
},
|
||||
dateTimeEventList: (input: DateTimeEventListInput) => {
|
||||
const maxValues = input.maxValues ?? 20;
|
||||
const dateTimeEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().trim().min(1).max(64),
|
||||
targetUtc: z.iso.datetime(),
|
||||
timeZone: z.string().min(1),
|
||||
startUtc: z.iso.datetime().optional(),
|
||||
recurrence: z.enum(["none", "yearly"]),
|
||||
});
|
||||
|
||||
return {
|
||||
type: "dateTimeEventList" as const,
|
||||
defaultValue: input.defaultValue ?? [],
|
||||
maxValues,
|
||||
timeZoneOptions: input.timeZoneOptions,
|
||||
withDescription: input.withDescription ?? false,
|
||||
validate: z
|
||||
.array(dateTimeEventSchema)
|
||||
.max(maxValues)
|
||||
.superRefine((values, ctx) => {
|
||||
const ids = new Set<string>();
|
||||
values.forEach((value, index) => {
|
||||
if (ids.has(value.id)) {
|
||||
ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" });
|
||||
}
|
||||
ids.add(value.id);
|
||||
});
|
||||
}),
|
||||
};
|
||||
},
|
||||
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
||||
type: "multiText" as const,
|
||||
defaultValue: input?.defaultValue ?? [],
|
||||
|
||||
@@ -13,6 +13,8 @@ export const widgetQueryRefetchIntervals = [
|
||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
|
||||
{ queryKey: [["widget", "weather", "atLocation"]], intervalSeconds: 600 },
|
||||
{ queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 },
|
||||
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
|
||||
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
|
||||
|
||||
@@ -0,0 +1,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}`;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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)" />}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user