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.
|
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
|
### Screenshots
|
||||||
|
|
||||||
import localClock from "./img/local.png";
|
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} />
|
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WidgetInteractionGuide advancedView={false} />
|
<WidgetInteractionGuide />
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
|
|||||||
values: { type: "string" },
|
values: { type: "string" },
|
||||||
defaultValue: "-",
|
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",
|
name: "Use fixed timezone",
|
||||||
description: "Select custom timezone to display instead of the client timezone.",
|
description: "Select custom timezone to display instead of the client timezone.",
|
||||||
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Date format",
|
name: "Date format",
|
||||||
description: "Format of the date displayed below the clock.",
|
description: "Choose a common Day.js date format with a live example and its tokens.",
|
||||||
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
|
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
|
||||||
defaultValue: "dddd, MMMM D",
|
defaultValue: "dddd, MMMM D",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Custom time format",
|
name: "Time format",
|
||||||
description: "Format of the time displayed in the clock.",
|
description:
|
||||||
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
|
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
|
||||||
defaultValue: "-",
|
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
|
||||||
|
defaultValue: "HH:mm",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Custom date format",
|
name: "Show weather",
|
||||||
description: "Format of the date displayed below the clock.",
|
description: "Show a compact weather summary from an independently configured location.",
|
||||||
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
|
values: { type: "boolean" },
|
||||||
defaultValue: "-",
|
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"]} />
|
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
|
||||||
|
|
||||||
The Weather Widget uses your location to display the current, highest and lowest temperature.
|
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
|
||||||
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
|
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
|
||||||
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
|
temperature readable before removing secondary details or forecast days.
|
||||||
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
|
|
||||||
Meteo response.
|
|
||||||
|
|
||||||
### 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";
|
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
|
||||||
import currentWeather from "./img/current.png";
|
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
|
||||||
import advancedWeather from "./img/advanced-view.png";
|
the standard desktop surface and fewer columns as the surface narrows.
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
|
||||||
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
|
reduced-motion preference always disables it.
|
||||||
<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>
|
|
||||||
|
|
||||||
<WidgetInteractionGuide />
|
<WidgetInteractionGuide />
|
||||||
|
|
||||||
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
|
|||||||
|
|
||||||
### Data source
|
### 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`
|
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: {
|
values: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
},
|
},
|
||||||
|
defaultValue: "no",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show humidity",
|
||||||
|
description: "Display humidity in current conditions, hourly details, and daily forecasts",
|
||||||
|
values: {
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
defaultValue: "yes",
|
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",
|
name: "Use imperial speed units",
|
||||||
description: "Display wind speed in mph instead of km/h",
|
description: "Display wind speed in mph instead of km/h",
|
||||||
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
|
|||||||
values: {
|
values: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
},
|
},
|
||||||
defaultValue: "no",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Has forecast",
|
name: "Has forecast",
|
||||||
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
|
|||||||
values: {
|
values: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
},
|
},
|
||||||
defaultValue: "no",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Amount of forecast days",
|
name: "Amount of forecast days",
|
||||||
description: "Number of days to show in the forecast",
|
description: "Number of days to show in the forecast",
|
||||||
values: "1-7",
|
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 { CommonWidgetProps, WidgetCard } from "./card";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
|
|||||||
return (
|
return (
|
||||||
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
<WidgetCard width={1} className={clsx("gap-2", className)}>
|
||||||
<div className="flex gap-2 justify-center items-center">
|
<div className="flex gap-2 justify-center items-center">
|
||||||
<IconCloud size={20} />
|
<IconSun size={22} />
|
||||||
<span className="text-xl font-bold">10.8°C</span>
|
<div className="flex flex-col leading-none">
|
||||||
</div>
|
<span className="text-xl font-bold">18.4°C</span>
|
||||||
<div className="flex gap-2 justify-center items-center">
|
<span className="text-xs text-muted-foreground">Paris</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center items-center">
|
</div>
|
||||||
<IconArrowDownRight size={14} />
|
<div className="flex justify-evenly items-center w-full">
|
||||||
<span className="text-sm">6.4°C</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</WidgetCard>
|
</WidgetCard>
|
||||||
|
|||||||
+4
@@ -2106,6 +2106,10 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"clock",
|
"clock",
|
||||||
"weather",
|
"weather",
|
||||||
|
"today",
|
||||||
|
"airQuality",
|
||||||
|
"countdown",
|
||||||
|
"timer",
|
||||||
"app",
|
"app",
|
||||||
"iframe",
|
"iframe",
|
||||||
"video",
|
"video",
|
||||||
|
|||||||
@@ -227,7 +227,9 @@ const LoadedBoardItemContent = ({
|
|||||||
(host: HTMLDivElement | null) => {
|
(host: HTMLDivElement | null) => {
|
||||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||||
cancelPreviewEntrance();
|
cancelPreviewEntrance();
|
||||||
host.append(surfacePortalTarget);
|
if (host.childNodes.length !== 1 || host.firstChild !== surfacePortalTarget) {
|
||||||
|
host.replaceChildren(surfacePortalTarget);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { iconsRouter } from "./router/icons";
|
|||||||
import { infoRouter } from "./router/info";
|
import { infoRouter } from "./router/info";
|
||||||
import { integrationRouter } from "./router/integration/integration-router";
|
import { integrationRouter } from "./router/integration/integration-router";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
|
import { airQualityRouter } from "./router/widgets/air-quality";
|
||||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||||
import { serverSettingsRouter } from "./router/serverSettings";
|
import { serverSettingsRouter } from "./router/serverSettings";
|
||||||
import { beszelRouter } from "./router/widgets/beszel";
|
import { beszelRouter } from "./router/widgets/beszel";
|
||||||
@@ -35,6 +36,7 @@ export const mcpRouter = createTRPCRouter({
|
|||||||
info: infoRouter,
|
info: infoRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
invite: inviteRouter,
|
invite: inviteRouter,
|
||||||
|
airQuality: airQualityRouter,
|
||||||
bazarr: bazarrRouter,
|
bazarr: bazarrRouter,
|
||||||
serverSettings: serverSettingsRouter,
|
serverSettings: serverSettingsRouter,
|
||||||
beszel: beszelRouter,
|
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)),
|
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
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)),
|
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
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";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
const atLocationInput = z.object({
|
const atLocationInput = z.object({
|
||||||
longitude: z.number(),
|
longitude: z.number().min(-180).max(180),
|
||||||
latitude: z.number(),
|
latitude: z.number().min(-90).max(90),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const weatherRouter = createTRPCRouter({
|
export const weatherRouter = createTRPCRouter({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ vi.mock("@homarr/auth", () => ({}));
|
|||||||
|
|
||||||
const MCP_TOOL_ALLOWLIST = {
|
const MCP_TOOL_ALLOWLIST = {
|
||||||
query: [
|
query: [
|
||||||
|
"airQuality_atLocation",
|
||||||
"apiKeys_getAll",
|
"apiKeys_getAll",
|
||||||
"app_all",
|
"app_all",
|
||||||
"app_byId",
|
"app_byId",
|
||||||
|
|||||||
@@ -336,6 +336,10 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/whats-up-docker"
|
| "/docs/integrations/whats-up-docker"
|
||||||
| "/docs/widgets/clock"
|
| "/docs/widgets/clock"
|
||||||
| "/docs/widgets/weather"
|
| "/docs/widgets/weather"
|
||||||
|
| "/docs/widgets/today"
|
||||||
|
| "/docs/widgets/air-quality"
|
||||||
|
| "/docs/widgets/countdown"
|
||||||
|
| "/docs/widgets/timer"
|
||||||
| "/docs/widgets/app"
|
| "/docs/widgets/app"
|
||||||
| "/docs/widgets/iframe"
|
| "/docs/widgets/iframe"
|
||||||
| "/docs/widgets/video"
|
| "/docs/widgets/video"
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { WidgetKind } from "../widget";
|
|||||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||||
clock: "clock",
|
clock: "clock",
|
||||||
weather: "weather",
|
weather: "weather",
|
||||||
|
today: "today",
|
||||||
|
airQuality: "air-quality",
|
||||||
|
countdown: "countdown",
|
||||||
|
timer: "timer",
|
||||||
app: "app",
|
app: "app",
|
||||||
iframe: "iframe",
|
iframe: "iframe",
|
||||||
video: "video",
|
video: "video",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export const widgetKinds = [
|
export const widgetKinds = [
|
||||||
"clock",
|
"clock",
|
||||||
"weather",
|
"weather",
|
||||||
|
"today",
|
||||||
|
"airQuality",
|
||||||
|
"countdown",
|
||||||
|
"timer",
|
||||||
"app",
|
"app",
|
||||||
"iframe",
|
"iframe",
|
||||||
"video",
|
"video",
|
||||||
@@ -59,6 +63,10 @@ export const widgetKinds = [
|
|||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|
||||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: 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 },
|
uptimeKuma: { width: 2, height: 3 },
|
||||||
audioStats: { width: 2, height: 2 },
|
audioStats: { width: 2, height: 2 },
|
||||||
paperlessNgx: { 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>> {
|
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||||
requestAsync: (input: TInput) => Promise<TData>;
|
requestAsync: (input: TInput) => Promise<TData>;
|
||||||
|
cacheTtlMs?: number;
|
||||||
|
fallbackToStaleOnError?: boolean;
|
||||||
|
staleIfErrorTtlMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||||
options: Options<TData, TInput>,
|
options: Options<TData, TInput>,
|
||||||
) => {
|
) => {
|
||||||
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
|
const inner = createRequestHandler<TData, TInput>(options);
|
||||||
return inner;
|
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 { z } from "zod";
|
||||||
|
|
||||||
|
import { ResponseError } from "@homarr/common/server";
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||||
|
|
||||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
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({
|
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
cacheTtlMs: 5 * 60 * 1000,
|
||||||
const res = await withTimeoutAsync(async (signal) => {
|
requestAsync: requestWeatherAsync,
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nullableNumberArray = z.array(z.number().nullable()).default([]);
|
||||||
|
const nullableStringArray = z.array(z.string().nullable()).default([]);
|
||||||
|
|
||||||
const atLocationOutput = z.object({
|
const atLocationOutput = z.object({
|
||||||
current_weather: z.object({
|
timezone: z.string().min(1),
|
||||||
weathercode: z.number(),
|
utc_offset_seconds: z.number(),
|
||||||
temperature: z.number(),
|
current: z.object({
|
||||||
windspeed: z.number(),
|
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({
|
daily: z.object({
|
||||||
time: z.array(z.string()),
|
time: z.array(z.string()),
|
||||||
weathercode: z.array(z.number()),
|
weather_code: nullableNumberArray,
|
||||||
temperature_2m_max: z.array(z.number()),
|
temperature_2m_max: nullableNumberArray,
|
||||||
temperature_2m_min: z.array(z.number()),
|
temperature_2m_min: nullableNumberArray,
|
||||||
sunrise: z.array(z.string()),
|
apparent_temperature_max: nullableNumberArray,
|
||||||
sunset: z.array(z.string()),
|
apparent_temperature_min: nullableNumberArray,
|
||||||
wind_speed_10m_max: z.array(z.number()),
|
precipitation_probability_max: nullableNumberArray,
|
||||||
wind_gusts_10m_max: z.array(z.number()),
|
precipitation_sum: nullableNumberArray,
|
||||||
relative_humidity_2m_mean: z.array(z.number()),
|
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 {
|
export interface Weather {
|
||||||
|
timezone: string;
|
||||||
|
utcOffsetSeconds: number;
|
||||||
current: {
|
current: {
|
||||||
weathercode: number;
|
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
apparentTemperature: number;
|
||||||
|
relativeHumidity: number;
|
||||||
|
precipitation: number;
|
||||||
|
weatherCode: number;
|
||||||
|
weathercode: number;
|
||||||
|
windSpeed: 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: {
|
daily: {
|
||||||
|
date: string;
|
||||||
time: string;
|
time: string;
|
||||||
weatherCode: number;
|
weatherCode: number;
|
||||||
|
maxTemperature: number | null;
|
||||||
maxTemp: number | undefined;
|
maxTemp: number | undefined;
|
||||||
|
minTemperature: number | null;
|
||||||
minTemp: number | undefined;
|
minTemp: number | undefined;
|
||||||
sunrise: string | undefined;
|
maxApparentTemperature: number | null;
|
||||||
sunset: string | undefined;
|
minApparentTemperature: number | null;
|
||||||
maxWindSpeed: number | undefined;
|
precipitationProbability: number | null;
|
||||||
maxWindGusts: number | undefined;
|
precipitation: number | null;
|
||||||
|
averageHumidity: number | null;
|
||||||
humidity: number | undefined;
|
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",
|
"label": "24-hour format",
|
||||||
"description": "Use 24-hour format instead of 12-hour format"
|
"description": "Use 24-hour format instead of 12-hour format"
|
||||||
},
|
},
|
||||||
"showSeconds": {
|
|
||||||
"label": "Display seconds"
|
|
||||||
},
|
|
||||||
"useCustomTimezone": {
|
"useCustomTimezone": {
|
||||||
"label": "Use a fixed timezone"
|
"label": "Use a fixed timezone"
|
||||||
},
|
},
|
||||||
@@ -3167,16 +3164,12 @@
|
|||||||
"label": "Show the date"
|
"label": "Show the date"
|
||||||
},
|
},
|
||||||
"dateFormat": {
|
"dateFormat": {
|
||||||
"label": "Date Format",
|
"label": "Date format",
|
||||||
"description": "How the date should look like"
|
"description": "Choose a Day.js preset. Each option shows a live example and its format tokens."
|
||||||
},
|
},
|
||||||
"customTimeFormat": {
|
"customTimeFormat": {
|
||||||
"label": "Custom time format",
|
"label": "Time format",
|
||||||
"description": "Use ISO 8601 to format time (this will override other options)"
|
"description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second."
|
||||||
},
|
|
||||||
"customDateFormat": {
|
|
||||||
"label": "Custom date format",
|
|
||||||
"description": "Use ISO 8601 to format date (this will override other options)"
|
|
||||||
},
|
},
|
||||||
"showWeather": {
|
"showWeather": {
|
||||||
"label": "Show weather",
|
"label": "Show weather",
|
||||||
@@ -3187,7 +3180,317 @@
|
|||||||
},
|
},
|
||||||
"isWeatherFormatFahrenheit": {
|
"isWeatherFormatFahrenheit": {
|
||||||
"label": "Use Fahrenheit"
|
"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": {
|
"minecraftServerStatus": {
|
||||||
@@ -3583,6 +3886,13 @@
|
|||||||
"disableTemperatureDecimals": {
|
"disableTemperatureDecimals": {
|
||||||
"label": "Disable temperature decimals"
|
"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": {
|
"showCurrentWindSpeed": {
|
||||||
"label": "Show current wind speed",
|
"label": "Show current wind speed",
|
||||||
"description": "Only on current weather"
|
"description": "Only on current weather"
|
||||||
@@ -3636,11 +3946,40 @@
|
|||||||
"detailsFor": "Show weather details for {date}",
|
"detailsFor": "Show weather details for {date}",
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"forecast": "7-day forecast",
|
"forecast": "7-day forecast",
|
||||||
|
"hourlyForecast": "Next 24 hours",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
|
"time": "Time",
|
||||||
"conditions": "Conditions",
|
"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",
|
"humidity": "Humidity",
|
||||||
|
"averageHumidity": "Average humidity: {humidity}%",
|
||||||
|
"currentHumidity": "Current humidity",
|
||||||
"wind": "Wind / gusts ({unit})",
|
"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",
|
"sun": "Sunrise / sunset",
|
||||||
"noForecast": "No forecast data available"
|
"noForecast": "No forecast data available"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"@mantine/dates": "catalog:",
|
"@mantine/dates": "catalog:",
|
||||||
"@mantine/form": "catalog:",
|
"@mantine/form": "catalog:",
|
||||||
"@mantine/hooks": "catalog:",
|
"@mantine/hooks": "catalog:",
|
||||||
|
"@rexa-developer/tiks": "catalog:",
|
||||||
"@tabler/icons-react": "catalog:",
|
"@tabler/icons-react": "catalog:",
|
||||||
"@tiptap/extension-color": "catalog:",
|
"@tiptap/extension-color": "catalog:",
|
||||||
"@tiptap/extension-highlight": "catalog:",
|
"@tiptap/extension-highlight": "catalog:",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
|||||||
import { WidgetAppInput } from "./widget-app-input";
|
import { WidgetAppInput } from "./widget-app-input";
|
||||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||||
import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-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 { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||||
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
|
import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input";
|
||||||
import { WidgetLocationInput } from "./widget-location-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 { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
|
||||||
import { WidgetSwitchInput } from "./widget-switch-input";
|
import { WidgetSwitchInput } from "./widget-switch-input";
|
||||||
import { WidgetTextInput } from "./widget-text-input";
|
import { WidgetTextInput } from "./widget-text-input";
|
||||||
|
import { WidgetTimezoneListInput } from "./widget-timezone-list-input";
|
||||||
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
|
import { WidgetUmamiEventInput } from "./widget-umami-event-input";
|
||||||
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
|
||||||
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
|
||||||
@@ -26,6 +28,8 @@ const mapping = {
|
|||||||
internal: WidgetInternalInput,
|
internal: WidgetInternalInput,
|
||||||
anchorNote: WidgetAnchorNoteInput,
|
anchorNote: WidgetAnchorNoteInput,
|
||||||
text: WidgetTextInput,
|
text: WidgetTextInput,
|
||||||
|
dateTimeEventList: WidgetDateTimeEventListInput,
|
||||||
|
timezoneList: WidgetTimezoneListInput,
|
||||||
location: WidgetLocationInput,
|
location: WidgetLocationInput,
|
||||||
multiSelect: WidgetMultiSelectInput,
|
multiSelect: WidgetMultiSelectInput,
|
||||||
multiText: WidgetMultiTextInput,
|
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}
|
description={options.withDescription ? tWidget("description") : undefined}
|
||||||
|
placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined}
|
||||||
searchable={options.searchable}
|
searchable={options.searchable}
|
||||||
{...inputProps}
|
{...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,
|
IconBuildingBank,
|
||||||
IconBusStop,
|
IconBusStop,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
|
IconCalendarWeek,
|
||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClock,
|
IconClock,
|
||||||
|
IconClockPlay,
|
||||||
IconCloud,
|
IconCloud,
|
||||||
IconDeviceCctv,
|
IconDeviceCctv,
|
||||||
IconDeviceGamepad,
|
IconDeviceGamepad,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconGraphFilled,
|
IconGraphFilled,
|
||||||
|
IconHourglass,
|
||||||
IconHeadphones,
|
IconHeadphones,
|
||||||
IconHeartRateMonitor,
|
IconHeartRateMonitor,
|
||||||
IconHeartbeat,
|
IconHeartbeat,
|
||||||
@@ -47,6 +50,7 @@ import {
|
|||||||
IconTransform,
|
IconTransform,
|
||||||
IconVideo,
|
IconVideo,
|
||||||
IconWall,
|
IconWall,
|
||||||
|
IconWind,
|
||||||
IconZoomQuestion,
|
IconZoomQuestion,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -56,6 +60,10 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||||
clock: IconClock,
|
clock: IconClock,
|
||||||
weather: IconCloud,
|
weather: IconCloud,
|
||||||
|
today: IconCalendarWeek,
|
||||||
|
airQuality: IconWind,
|
||||||
|
countdown: IconHourglass,
|
||||||
|
timer: IconClockPlay,
|
||||||
app: IconApps,
|
app: IconApps,
|
||||||
assistant: IconRobot,
|
assistant: IconRobot,
|
||||||
iframe: IconBrowser,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { Box, Center, Stack, Text, Title } from "@mantine/core";
|
||||||
import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
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 type { WidgetComponentProps } from "../definition";
|
||||||
import { AnimatedWeatherIcon } from "../weather/animated-icon";
|
import { AdvancedClockView } from "./advanced-view";
|
||||||
import { WeatherDescription } from "../weather/icon";
|
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(advancedFormat);
|
||||||
dayjs.extend(utc);
|
|
||||||
dayjs.extend(timezones);
|
|
||||||
|
|
||||||
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
|
||||||
const isAdvanced = displayMode === "advanced";
|
const isAdvanced = displayMode === "advanced";
|
||||||
const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : "";
|
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
|
||||||
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
|
const time = useCurrentTime({ showSeconds });
|
||||||
const dateFormat = options.dateFormat;
|
const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone();
|
||||||
const customTimeFormat = options.customTimeFormat;
|
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
|
||||||
const customDateFormat = options.customDateFormat;
|
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
|
||||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
|
||||||
const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced });
|
|
||||||
const zonedTime = time === null ? null : dayjs(time).tz(timezone);
|
|
||||||
|
|
||||||
|
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 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";
|
const showWeatherCorner = options.showWeather && sizing !== "xs";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="clock-widget-container" h="100%" pos="relative">
|
<Box className="clock-widget-container" h="100%" pos="relative">
|
||||||
{showWeatherCorner && (
|
{showWeatherCorner && (
|
||||||
<ClockWeatherCorner
|
<Box pos="absolute" top={4} left={4}>
|
||||||
latitude={options.weatherLocation.latitude}
|
<ClockWeatherSummary
|
||||||
longitude={options.weatherLocation.longitude}
|
latitude={options.weatherLocation.latitude}
|
||||||
locationName={options.weatherLocation.name}
|
longitude={options.weatherLocation.longitude}
|
||||||
isFahrenheit={options.isWeatherFormatFahrenheit}
|
locationName={options.weatherLocation.name}
|
||||||
showDetails={isAdvanced}
|
isFahrenheit={options.isWeatherFormatFahrenheit}
|
||||||
/>
|
animateIcon={options.animateWeatherIcon}
|
||||||
|
detailed={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap={sizing}>
|
||||||
{options.customTitleToggle && (
|
{options.customTitleToggle && (
|
||||||
@@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
|||||||
{options.customTitle}
|
{options.customTitle}
|
||||||
</Text>
|
</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()}>
|
<time dateTime={zonedTime?.toISOString()}>
|
||||||
{zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)}
|
{zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)}
|
||||||
</time>
|
</time>
|
||||||
</Title>
|
</Title>
|
||||||
{(isAdvanced || options.showDate) && (
|
{options.showDate && (
|
||||||
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
<Text className="clock-date-text" size={sizing} lineClamp={1}>
|
||||||
{zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)}
|
{zonedTime?.format(options.dateFormat)}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{isAdvanced && (
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -73,117 +79,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClockWeatherCornerProps {
|
const getTitleOrder = (sizing: "xs" | "sm" | "md") => {
|
||||||
latitude: number;
|
if (sizing === "md") return 2;
|
||||||
longitude: number;
|
if (sizing === "sm") return 4;
|
||||||
locationName: string;
|
return 6;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
@@ -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 { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
import {
|
||||||
|
defaultWorldClockCities,
|
||||||
|
getTimeZoneOptions,
|
||||||
|
maximumWorldClockCities,
|
||||||
|
worldClockCityPresets,
|
||||||
|
} from "./world-clock";
|
||||||
|
|
||||||
|
const timeZoneOptions = getTimeZoneOptions();
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||||
icon: IconClock,
|
icon: IconClock,
|
||||||
|
supportsAdvancedFocus: true,
|
||||||
queryKey: [["widget", "weather", "atLocation"]],
|
queryKey: [["widget", "weather", "atLocation"]],
|
||||||
queryMatcher: ({ input }, scope) => {
|
queryMatcher: ({ input }, scope) => {
|
||||||
const location = scope.options.weatherLocation;
|
const location = scope.options.weatherLocation;
|
||||||
@@ -37,12 +46,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
}),
|
}),
|
||||||
showSeconds: factory.switch({
|
|
||||||
defaultValue: false,
|
|
||||||
}),
|
|
||||||
useCustomTimezone: factory.switch({ defaultValue: false }),
|
useCustomTimezone: factory.switch({ defaultValue: false }),
|
||||||
timezone: factory.select({
|
timezone: factory.select({
|
||||||
options: Intl.supportedValuesOf("timeZone").map((value) => value),
|
options: timeZoneOptions,
|
||||||
defaultValue: "Europe/London",
|
defaultValue: "Europe/London",
|
||||||
searchable: true,
|
searchable: true,
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
@@ -52,24 +58,29 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
|||||||
}),
|
}),
|
||||||
dateFormat: factory.select({
|
dateFormat: factory.select({
|
||||||
options: [
|
options: [
|
||||||
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
|
{ value: "YYYY-MM-DD", label: `${dayjs().format("YYYY-MM-DD")} · YYYY-MM-DD` },
|
||||||
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
|
{ value: "DD/MM/YYYY", label: `${dayjs().format("DD/MM/YYYY")} · DD/MM/YYYY` },
|
||||||
{ value: "MMM D", label: dayjs().format("MMM D") },
|
{ value: "MM/DD/YYYY", label: `${dayjs().format("MM/DD/YYYY")} · MM/DD/YYYY` },
|
||||||
{ value: "D MMM", label: dayjs().format("D MMM") },
|
{ value: "dddd, MMMM D", label: `${dayjs().format("dddd, MMMM D")} · dddd, MMMM D` },
|
||||||
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
|
{ value: "dddd, D MMMM", label: `${dayjs().format("dddd, D MMMM")} · dddd, D MMMM` },
|
||||||
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
|
{ value: "MMM D, YYYY", label: `${dayjs().format("MMM D, YYYY")} · MMM D, YYYY` },
|
||||||
{ value: "DD/MM", label: dayjs().format("DD/MM") },
|
{ value: "D MMM YYYY", label: `${dayjs().format("D MMM YYYY")} · D MMM YYYY` },
|
||||||
{ value: "MM/DD", label: dayjs().format("MM/DD") },
|
{ 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",
|
defaultValue: "dddd, MMMM D",
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
}),
|
}),
|
||||||
customTimeFormat: factory.text({
|
customTimeFormat: factory.select({
|
||||||
defaultValue: "",
|
options: [
|
||||||
withDescription: true,
|
{ value: "HH:mm", label: `${dayjs().format("HH:mm")} · HH:mm` },
|
||||||
}),
|
{ value: "HH:mm:ss", label: `${dayjs().format("HH:mm:ss")} · HH:mm:ss` },
|
||||||
customDateFormat: factory.text({
|
{ value: "h:mm A", label: `${dayjs().format("h:mm A")} · h:mm A` },
|
||||||
defaultValue: "",
|
{ value: "h:mm:ss A", label: `${dayjs().format("h:mm:ss A")} · h:mm:ss A` },
|
||||||
|
],
|
||||||
|
defaultValue: "HH:mm",
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
}),
|
}),
|
||||||
showWeather: factory.switch({
|
showWeather: factory.switch({
|
||||||
@@ -86,8 +97,22 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
|||||||
isWeatherFormatFahrenheit: factory.switch({
|
isWeatherFormatFahrenheit: factory.switch({
|
||||||
defaultValue: false,
|
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: {
|
customTitle: {
|
||||||
shouldHide: (options) => !options.customTitleToggle,
|
shouldHide: (options) => !options.customTitleToggle,
|
||||||
},
|
},
|
||||||
@@ -103,6 +128,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
|||||||
isWeatherFormatFahrenheit: {
|
isWeatherFormatFahrenheit: {
|
||||||
shouldHide: (options) => !options.showWeather,
|
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,
|
beszelSystemGridDefinition,
|
||||||
beszelSystemStatsDefinition,
|
beszelSystemStatsDefinition,
|
||||||
beszelSystemTableDefinition,
|
beszelSystemTableDefinition,
|
||||||
clockDefinition,
|
|
||||||
customApiDefinition,
|
customApiDefinition,
|
||||||
dockerDefinition,
|
dockerDefinition,
|
||||||
indexerManagerDefinition,
|
indexerManagerDefinition,
|
||||||
@@ -49,6 +48,7 @@ describe("supportsAdvancedFocus", () => {
|
|||||||
|
|
||||||
test("requires an explicit opt-in", () => {
|
test("requires an explicit opt-in", () => {
|
||||||
expect(supportsAdvancedFocus({})).toBe(false);
|
expect(supportsAdvancedFocus({})).toBe(false);
|
||||||
|
expect(supportsAdvancedFocus(clockDefinition)).toBe(true);
|
||||||
expect(supportsAdvancedFocus(downloadsDefinition)).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 beszelSystemStats from "./beszel-system-stats";
|
||||||
import * as video from "./video";
|
import * as video from "./video";
|
||||||
import * as weather from "./weather";
|
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 customApi from "./custom-api";
|
||||||
import * as assistant from "./assistant";
|
import * as assistant from "./assistant";
|
||||||
import * as wud from "./wud";
|
import * as wud from "./wud";
|
||||||
@@ -94,6 +98,10 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
|
|||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
clock,
|
clock,
|
||||||
weather,
|
weather,
|
||||||
|
today,
|
||||||
|
airQuality,
|
||||||
|
countdown,
|
||||||
|
timer,
|
||||||
app,
|
app,
|
||||||
archiveTeamWarrior,
|
archiveTeamWarrior,
|
||||||
anchorNote,
|
anchorNote,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { widgetQueryRefetchIntervals } from "./refetch-intervals";
|
|||||||
import { widgetCatalogIcons } from "./catalog";
|
import { widgetCatalogIcons } from "./catalog";
|
||||||
import { getWidgetQueryKeys } from "./definition";
|
import { getWidgetQueryKeys } from "./definition";
|
||||||
|
|
||||||
|
const serializePollingPolicy = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry);
|
||||||
|
|
||||||
describe("widget manifest promise stability", () => {
|
describe("widget manifest promise stability", () => {
|
||||||
it("returns the same module promise for every render", () => {
|
it("returns the same module promise for every render", () => {
|
||||||
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
|
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(serializePollingPolicy).toSorted()).toEqual(
|
||||||
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
|
[...expectedByQueryKey.values()].map(serializePollingPolicy).toSorted(),
|
||||||
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type WidgetResources = {
|
|||||||
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||||
clock: () => import("./clock") as Promise<WidgetModule>,
|
clock: () => import("./clock") as Promise<WidgetModule>,
|
||||||
weather: () => import("./weather") 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>,
|
app: () => import("./app") as Promise<WidgetModule>,
|
||||||
assistant: () => import("./assistant") as Promise<WidgetModule>,
|
assistant: () => import("./assistant") as Promise<WidgetModule>,
|
||||||
archiveTeamWarrior: () => import("./archive-team-warrior") 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>> = {
|
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
|
||||||
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
|
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
|
||||||
weather: () => import("./weather/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>,
|
app: () => import("./app/component") as Promise<WidgetComponentModule>,
|
||||||
assistant: () => import("./assistant/component") as Promise<WidgetComponentModule>,
|
assistant: () => import("./assistant/component") as Promise<WidgetComponentModule>,
|
||||||
archiveTeamWarrior: () => import("./archive-team-warrior/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;
|
options: TOptions;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
withPlaceholder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
||||||
@@ -107,6 +108,34 @@ interface SliderInput extends CommonInput<number> {
|
|||||||
step?: 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 {
|
export interface OptionLocation {
|
||||||
name: string;
|
name: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
@@ -163,6 +192,7 @@ const optionsFactory = {
|
|||||||
options: input.options,
|
options: input.options,
|
||||||
searchable: input.searchable ?? false,
|
searchable: input.searchable ?? false,
|
||||||
withDescription: input.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
|
withPlaceholder: input.withPlaceholder ?? false,
|
||||||
}),
|
}),
|
||||||
number: (input: NumberInput) => ({
|
number: (input: NumberInput) => ({
|
||||||
type: "number" as const,
|
type: "number" as const,
|
||||||
@@ -192,6 +222,72 @@ const optionsFactory = {
|
|||||||
longitude: z.number(),
|
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 }) => ({
|
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
||||||
type: "multiText" as const,
|
type: "multiText" as const,
|
||||||
defaultValue: input?.defaultValue ?? [],
|
defaultValue: input?.defaultValue ?? [],
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export const widgetQueryRefetchIntervals = [
|
|||||||
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
|
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
|
||||||
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
|
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
|
||||||
{ queryKey: [["widget", "tracearr"]], 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", "getAlbum"]], intervalSeconds: null },
|
||||||
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
|
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
|
||||||
{ queryKey: [["widget", "immich", "getAlbums"]], 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";
|
import { WeatherIcon } from "./icon";
|
||||||
|
|
||||||
interface AnimatedWeatherIconProps {
|
interface AnimatedWeatherIconProps {
|
||||||
|
animated?: boolean;
|
||||||
code: number;
|
code: number;
|
||||||
|
isDay?: boolean;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,10 +19,12 @@ const getAnimationClass = (code: number): string => {
|
|||||||
return "";
|
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 (
|
return (
|
||||||
<span className={`weather-anim-wrapper ${getAnimationClass(code)}`}>
|
<span className={`weather-anim-wrapper ${animationClass}`}>
|
||||||
<WeatherIcon code={code} size={size} />
|
<WeatherIcon code={code} isDay={isDay} size={size} />
|
||||||
</span>
|
</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,
|
.detailsButton,
|
||||||
.forecastButton {
|
.forecastButton,
|
||||||
|
.dailyCardButton {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-radius: var(--mantine-radius-sm);
|
border-radius: var(--mantine-radius-sm);
|
||||||
}
|
}
|
||||||
@@ -8,24 +88,52 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 24px;
|
min-width: 1.5rem;
|
||||||
min-height: 24px;
|
min-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forecastButton {
|
.forecastButton,
|
||||||
|
.dailyCardButton {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dailyCardButton,
|
||||||
|
.dailyCard {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.detailsButton:focus-visible,
|
.detailsButton:focus-visible,
|
||||||
.forecastButton:focus-visible {
|
.forecastButton:focus-visible,
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
.dailyCardButton:focus-visible {
|
||||||
outline-offset: 2px;
|
outline: 0.125rem solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorIndicator {
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (any-pointer: coarse) {
|
@media (any-pointer: coarse) {
|
||||||
.detailsButton,
|
.detailsButton,
|
||||||
.forecastButton {
|
.forecastButton,
|
||||||
min-width: 44px;
|
.dailyCardButton {
|
||||||
min-height: 44px;
|
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 { 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 { formatWeatherDate } from "./icon";
|
||||||
|
import { getAdvancedWeatherLayout, getCompactWeatherLayout } from "./layout";
|
||||||
|
|
||||||
describe("weather formatting", () => {
|
describe("weather formatting", () => {
|
||||||
it("preserves valid zero temperatures", () => {
|
it("preserves valid zero values", () => {
|
||||||
expect(getPreferredUnit(0)).toBe("0.0°C");
|
expect(getPreferredUnit(0)).toBe("0.0°C");
|
||||||
expect(getPreferredUnit(0, true, true)).toBe("32°F");
|
expect(getPreferredUnit(0, true, true)).toBe("32°F");
|
||||||
|
expect(getPreferredWindSpeed(0)).toBe("0.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps zero wind speeds and missing forecast values truthful", () => {
|
it("keeps missing and invalid values truthful", () => {
|
||||||
expect(getPreferredWindSpeed(0)).toBe("0.0");
|
expect(getPreferredUnit(null)).toBe("?");
|
||||||
expect(getPreferredWindSpeed(undefined)).toBe("?");
|
expect(getPreferredWindSpeed(undefined)).toBe("?");
|
||||||
expect(getPreferredTime(undefined, "en-US")).toBe("?");
|
expect(getPreferredTime(undefined, "en-US")).toBe("?");
|
||||||
expect(getPreferredTime("invalid", "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([
|
it.each([
|
||||||
["dddd, MMMM D", "Monday, August 3"],
|
["dddd, MMMM D", "Monday, August 3"],
|
||||||
["dddd, D MMMM", "Monday, 3 August"],
|
["dddd, D MMMM", "Monday, 3 August"],
|
||||||
@@ -34,3 +33,61 @@ describe("weather formatting", () => {
|
|||||||
expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected);
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useId } from "react";
|
import { Box } from "@mantine/core";
|
||||||
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 type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { metricToImperial } from "@homarr/common";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
|
|
||||||
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
|
||||||
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { AnimatedWeatherIcon } from "./animated-icon";
|
import { AdvancedWeather } from "./advanced";
|
||||||
import { formatWeatherDate, WeatherDescription } from "./icon";
|
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
import { CompactWeather } from "./compact";
|
||||||
|
|
||||||
export default function WeatherWidget({
|
export default function WeatherWidget({
|
||||||
isEditMode,
|
isEditMode,
|
||||||
@@ -46,419 +31,20 @@ export default function WeatherWidget({
|
|||||||
if (isInitialWidgetQueryPending(weatherQuery)) return <WidgetQueryLoadingState />;
|
if (isInitialWidgetQueryPending(weatherQuery)) return <WidgetQueryLoadingState />;
|
||||||
if (!weather) return <WidgetEmptyState />;
|
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 (
|
return (
|
||||||
<Box h="100%" w="100%" pos="relative">
|
<Box className={classes.weatherSurface} h="100%" w="100%" pos="relative">
|
||||||
{content}
|
<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 && (
|
{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")} />
|
<WidgetQueryErrorIndicator error={weatherQuery.error} label={t("name")} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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 {
|
interface WeatherIconProps {
|
||||||
code: number;
|
code: number;
|
||||||
|
isDay?: boolean;
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,12 +33,16 @@ interface WeatherIconProps {
|
|||||||
* @param size size of the icon, accepts relative sizes too
|
* @param size size of the icon, accepts relative sizes too
|
||||||
* @returns Icon corresponding to the weather code
|
* @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;
|
const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather;
|
||||||
|
|
||||||
return <Icon style={{ float: "left" }} size={size} />;
|
return <Icon style={{ float: "left" }} size={size} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getWeatherKind = (code: number) =>
|
||||||
|
(weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather).name;
|
||||||
|
|
||||||
interface WeatherDescriptionProps {
|
interface WeatherDescriptionProps {
|
||||||
weatherOnly?: boolean;
|
weatherOnly?: boolean;
|
||||||
useImperialSpeed?: boolean;
|
useImperialSpeed?: boolean;
|
||||||
@@ -84,23 +89,33 @@ export const WeatherDescription = ({
|
|||||||
const tCommon = useScopedI18n("common");
|
const tCommon = useScopedI18n("common");
|
||||||
const locale = useCurrentIntlLocale();
|
const locale = useCurrentIntlLocale();
|
||||||
|
|
||||||
const { name } = weatherDefinitions.find((definition) => definition.codes.includes(weatherCode)) ?? unknownWeather;
|
const name = getWeatherKind(weatherCode);
|
||||||
|
|
||||||
if (weatherOnly) {
|
if (weatherOnly) {
|
||||||
return <Text fz="16px">{t(`kind.${name}`)}</Text>;
|
return <Text fz="md">{t(`kind.${name}`)}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack align="center" gap="0">
|
<Stack align="center" gap="0">
|
||||||
<Text fz="24px">{formatWeatherDate(time, locale, dateFormat)}</Text>
|
<Text fz="xl">{formatWeatherDate(time, locale, dateFormat)}</Text>
|
||||||
<Text fz="16px">{t(`kind.${name}`)}</Text>
|
<Text fz="md">{t(`kind.${name}`)}</Text>
|
||||||
<List>
|
<List>
|
||||||
<List.Item icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
<List.Item
|
||||||
<List.Item icon={<IconTemperatureMinus size="var(--mantine-font-size-sm)" />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
|
icon={<IconTemperaturePlus size="var(--mantine-font-size-sm)" />}
|
||||||
<List.Item icon={<IconSun size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
|
>{`${tCommon("information.max")}: ${maxTemp}`}</List.Item>
|
||||||
<List.Item icon={<IconMoon size="var(--mantine-font-size-sm)" />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</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 && (
|
{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 && (
|
{maxWindSpeed !== undefined && (
|
||||||
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
<List.Item icon={<IconWind size="var(--mantine-font-size-sm)" />}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
icon: IconCloud,
|
icon: IconCloud,
|
||||||
supportsAdvancedFocus: true,
|
supportsAdvancedFocus: true,
|
||||||
queryKey: [["widget", "weather", "atLocation"]],
|
queryKey: [["widget", "weather", "atLocation"]],
|
||||||
|
refetchInterval: 600,
|
||||||
queryMatcher: ({ input }, scope) => {
|
queryMatcher: ({ input }, scope) => {
|
||||||
const location = scope.options.location;
|
const location = scope.options.location;
|
||||||
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
|
if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) {
|
||||||
@@ -24,6 +25,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
(factory) => ({
|
(factory) => ({
|
||||||
isFormatFahrenheit: factory.switch(),
|
isFormatFahrenheit: factory.switch(),
|
||||||
disableTemperatureDecimals: factory.switch(),
|
disableTemperatureDecimals: factory.switch(),
|
||||||
|
animateIcons: factory.switch(),
|
||||||
|
showHumidity: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
||||||
useImperialSpeed: factory.switch(),
|
useImperialSpeed: factory.switch(),
|
||||||
location: factory.location({
|
location: factory.location({
|
||||||
@@ -47,10 +50,10 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
defaultValue: "dddd, MMMM D",
|
defaultValue: "dddd, MMMM D",
|
||||||
withDescription: true,
|
withDescription: true,
|
||||||
}),
|
}),
|
||||||
showCity: factory.switch(),
|
showCity: factory.switch({ defaultValue: true }),
|
||||||
hasForecast: factory.switch(),
|
hasForecast: factory.switch({ defaultValue: true }),
|
||||||
forecastDayCount: factory.slider({
|
forecastDayCount: factory.slider({
|
||||||
defaultValue: 5,
|
defaultValue: 3,
|
||||||
validate: z.number().min(1).max(7),
|
validate: z.number().min(1).max(7),
|
||||||
step: 1,
|
step: 1,
|
||||||
withDescription: true,
|
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':
|
'@mantine/hooks':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 9.4.1(react@19.2.7)
|
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':
|
'@tabler/icons-react':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.44.0(react@19.2.7)
|
version: 3.44.0(react@19.2.7)
|
||||||
|
|||||||
Reference in new issue
Block a user