From 56c2c4dcc08ca7f8b090fa287c9081563b080a87 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 20 Aug 2026 15:14:31 +0200 Subject: [PATCH] feat: expand utility widgets --- apps/docs/docs/widgets/air-quality/index.mdx | 47 ++ apps/docs/docs/widgets/air-quality/index.ts | 43 ++ apps/docs/docs/widgets/clock/index.mdx | 22 +- apps/docs/docs/widgets/clock/index.ts | 57 +- apps/docs/docs/widgets/countdown/index.mdx | 44 ++ apps/docs/docs/widgets/countdown/index.ts | 31 ++ apps/docs/docs/widgets/timer/index.mdx | 52 ++ apps/docs/docs/widgets/timer/index.ts | 61 +++ apps/docs/docs/widgets/today/index.mdx | 34 ++ apps/docs/docs/widgets/today/index.ts | 43 ++ apps/docs/docs/widgets/weather/index.mdx | 33 +- apps/docs/docs/widgets/weather/index.tsx | 22 +- .../home/hero/widgets/weather-widget.tsx | 36 +- .../api/src/router/widgets/air-quality.ts | 20 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/weather.ts | 4 +- .../src/docs/homarr-docs-sitemap.ts | 4 + .../definitions/src/docs/widget-doc-slugs.ts | 4 + packages/definitions/src/widget.ts | 8 + .../request-handler/src/air-quality.spec.ts | 101 ++++ packages/request-handler/src/air-quality.ts | 283 ++++++++++ .../src/lib/widget-request-handler.ts | 5 +- packages/request-handler/src/weather.spec.ts | 110 ++++ packages/request-handler/src/weather.ts | 267 ++++++++-- packages/translation/src/lang/en.json | 344 +++++++++++- packages/widgets/package.json | 1 + packages/widgets/src/_inputs/index.ts | 4 + .../widget-date-time-event-list-input.tsx | 317 +++++++++++ .../src/_inputs/widget-select-input.tsx | 1 + .../_inputs/widget-timezone-list-input.tsx | 249 +++++++++ .../widgets/src/air-quality/component.tsx | 394 ++++++++++++++ .../widgets/src/air-quality/display.spec.ts | 64 +++ packages/widgets/src/air-quality/display.ts | 139 +++++ packages/widgets/src/air-quality/index.ts | 43 ++ packages/widgets/src/catalog.ts | 8 + packages/widgets/src/clock/advanced-view.tsx | 329 ++++++++++++ packages/widgets/src/clock/component.tsx | 201 ++----- packages/widgets/src/clock/format.ts | 19 + packages/widgets/src/clock/index.ts | 64 ++- .../widgets/src/clock/time-of-day-bar.tsx | 106 ++++ .../widgets/src/clock/use-current-time.ts | 27 + .../widgets/src/clock/weather-summary.tsx | 78 +++ .../widgets/src/clock/world-clock.spec.ts | 63 +++ packages/widgets/src/clock/world-clock.ts | 127 +++++ .../widgets/src/common/browser-alert.spec.ts | 38 ++ packages/widgets/src/common/browser-alert.ts | 87 +++ .../src/common/use-local-widget-state.spec.ts | 28 + .../src/common/use-local-widget-state.ts | 145 +++++ .../widgets/src/common/use-widget-now.spec.ts | 16 + packages/widgets/src/common/use-widget-now.ts | 54 ++ .../src/countdown/component.module.css | 42 ++ packages/widgets/src/countdown/component.tsx | 361 +++++++++++++ packages/widgets/src/countdown/display.ts | 64 +++ packages/widgets/src/countdown/index.ts | 24 + .../widgets/src/countdown/recurrence.spec.ts | 101 ++++ packages/widgets/src/countdown/recurrence.ts | 226 ++++++++ packages/widgets/src/index.tsx | 8 + packages/widgets/src/manifest.spec.ts | 7 +- packages/widgets/src/manifest.ts | 8 + packages/widgets/src/options.ts | 96 ++++ packages/widgets/src/refetch-intervals.ts | 2 + .../widgets/src/timer/component.module.css | 73 +++ packages/widgets/src/timer/component.tsx | 504 ++++++++++++++++++ packages/widgets/src/timer/index.ts | 54 ++ packages/widgets/src/timer/runtime.spec.ts | 91 ++++ packages/widgets/src/timer/runtime.ts | 287 ++++++++++ packages/widgets/src/today/component.tsx | 220 ++++++++ packages/widgets/src/today/date-stats.spec.ts | 47 ++ packages/widgets/src/today/date-stats.ts | 172 ++++++ packages/widgets/src/today/index.ts | 24 + packages/widgets/src/weather/advanced.tsx | 439 +++++++++++++++ .../widgets/src/weather/animated-icon.tsx | 9 +- packages/widgets/src/weather/compact.tsx | 169 ++++++ .../widgets/src/weather/component.module.css | 116 +++- .../widgets/src/weather/component.spec.ts | 75 ++- packages/widgets/src/weather/component.tsx | 443 +-------------- packages/widgets/src/weather/details.tsx | 161 ++++++ packages/widgets/src/weather/format.ts | 60 +++ packages/widgets/src/weather/icon.tsx | 29 +- packages/widgets/src/weather/index.ts | 9 +- packages/widgets/src/weather/layout.ts | 58 ++ pnpm-lock.yaml | 3 + 82 files changed, 7520 insertions(+), 740 deletions(-) create mode 100644 apps/docs/docs/widgets/air-quality/index.mdx create mode 100644 apps/docs/docs/widgets/air-quality/index.ts create mode 100644 apps/docs/docs/widgets/countdown/index.mdx create mode 100644 apps/docs/docs/widgets/countdown/index.ts create mode 100644 apps/docs/docs/widgets/timer/index.mdx create mode 100644 apps/docs/docs/widgets/timer/index.ts create mode 100644 apps/docs/docs/widgets/today/index.mdx create mode 100644 apps/docs/docs/widgets/today/index.ts create mode 100644 packages/api/src/router/widgets/air-quality.ts create mode 100644 packages/request-handler/src/air-quality.spec.ts create mode 100644 packages/request-handler/src/air-quality.ts create mode 100644 packages/request-handler/src/weather.spec.ts create mode 100644 packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx create mode 100644 packages/widgets/src/_inputs/widget-timezone-list-input.tsx create mode 100644 packages/widgets/src/air-quality/component.tsx create mode 100644 packages/widgets/src/air-quality/display.spec.ts create mode 100644 packages/widgets/src/air-quality/display.ts create mode 100644 packages/widgets/src/air-quality/index.ts create mode 100644 packages/widgets/src/clock/advanced-view.tsx create mode 100644 packages/widgets/src/clock/format.ts create mode 100644 packages/widgets/src/clock/time-of-day-bar.tsx create mode 100644 packages/widgets/src/clock/use-current-time.ts create mode 100644 packages/widgets/src/clock/weather-summary.tsx create mode 100644 packages/widgets/src/clock/world-clock.spec.ts create mode 100644 packages/widgets/src/clock/world-clock.ts create mode 100644 packages/widgets/src/common/browser-alert.spec.ts create mode 100644 packages/widgets/src/common/browser-alert.ts create mode 100644 packages/widgets/src/common/use-local-widget-state.spec.ts create mode 100644 packages/widgets/src/common/use-local-widget-state.ts create mode 100644 packages/widgets/src/common/use-widget-now.spec.ts create mode 100644 packages/widgets/src/common/use-widget-now.ts create mode 100644 packages/widgets/src/countdown/component.module.css create mode 100644 packages/widgets/src/countdown/component.tsx create mode 100644 packages/widgets/src/countdown/display.ts create mode 100644 packages/widgets/src/countdown/index.ts create mode 100644 packages/widgets/src/countdown/recurrence.spec.ts create mode 100644 packages/widgets/src/countdown/recurrence.ts create mode 100644 packages/widgets/src/timer/component.module.css create mode 100644 packages/widgets/src/timer/component.tsx create mode 100644 packages/widgets/src/timer/index.ts create mode 100644 packages/widgets/src/timer/runtime.spec.ts create mode 100644 packages/widgets/src/timer/runtime.ts create mode 100644 packages/widgets/src/today/component.tsx create mode 100644 packages/widgets/src/today/date-stats.spec.ts create mode 100644 packages/widgets/src/today/date-stats.ts create mode 100644 packages/widgets/src/today/index.ts create mode 100644 packages/widgets/src/weather/advanced.tsx create mode 100644 packages/widgets/src/weather/compact.tsx create mode 100644 packages/widgets/src/weather/details.tsx create mode 100644 packages/widgets/src/weather/format.ts create mode 100644 packages/widgets/src/weather/layout.ts diff --git a/apps/docs/docs/widgets/air-quality/index.mdx b/apps/docs/docs/widgets/air-quality/index.mdx new file mode 100644 index 000000000..c1b7dd4bd --- /dev/null +++ b/apps/docs/docs/widgets/air-quality/index.mdx @@ -0,0 +1,47 @@ +--- +title: "Air Quality & UV" +description: "Displays air quality, ultraviolet index, pollutants and pollen for a location." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { airQualityWidget } from "."; + + + +The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index, +dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced +view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima. + +Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose +locale region is the United States and the European scale everywhere else. The selected scale is always shown next +to the value. Pollen values are individual measured concentrations; the widget does not combine them into a single +pollen index. + +The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use +micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter. + + + +### Adding the widget + + + +### Configuration + + + +### Data source + +Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a +seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon +monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the +server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24 +hours instead of replacing it with an empty state. + +Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the +service, use `https://air-quality-api.open-meteo.com`. diff --git a/apps/docs/docs/widgets/air-quality/index.ts b/apps/docs/docs/widgets/air-quality/index.ts new file mode 100644 index 000000000..e1d3761ff --- /dev/null +++ b/apps/docs/docs/widgets/air-quality/index.ts @@ -0,0 +1,43 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconWind } from "@tabler/icons-react"; + +export const airQualityWidget: WidgetDefinition = { + icon: IconWind, + name: "Air Quality & UV", + description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.", + path: "../../widgets/air-quality", + configuration: { + items: [ + { + name: "Air quality location", + description: "Location used for air quality, UV and pollen data", + values: "Select a location through search or longitude/latitude", + defaultValue: "Paris / 48.85341, 2.3488", + }, + { + name: "AQI standard", + description: "Choose the European or US air quality scale, or select one from the viewer's locale", + values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] }, + defaultValue: "Automatic for locale", + }, + { + name: "Show UV index", + description: "Display the current UV index and its forecast", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show pollutant concentrations", + description: "Display particulate matter and gaseous pollutant concentrations", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show pollen concentrations", + description: "Display available pollen concentrations", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index 6ec75e9f6..1f453d33f 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -15,6 +15,26 @@ import { clockWidget } from "."; The clock widget displays the current date and time in a customizable format. +Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and +Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options; +London, Sydney, and UTC are also available as quick presets. + +The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise, +comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining +time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not +astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets. + +The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live +examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and +24-hour display are selected through those same formats. The date-format selector likewise +shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a +Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone +rules, including daylight-saving changes. + +Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by +default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved +timezone is identified without preventing valid clocks from rendering. + ### Screenshots import localClock from "./img/local.png"; @@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png"; clock format-12-hours timezone-tokyo - + ### Adding the widget diff --git a/apps/docs/docs/widgets/clock/index.ts b/apps/docs/docs/widgets/clock/index.ts index 5cdb1e2a8..f34cae4ed 100644 --- a/apps/docs/docs/widgets/clock/index.ts +++ b/apps/docs/docs/widgets/clock/index.ts @@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = { values: { type: "string" }, defaultValue: "-", }, - { - name: "24-hour format", - description: "Use 24-hour format instead of 12-hour format.", - values: { type: "boolean" }, - defaultValue: "yes", - }, - { - name: "Display seconds", - description: "Whether to show seconds in the clock.", - values: { type: "boolean" }, - defaultValue: "no", - }, { name: "Use fixed timezone", description: "Select custom timezone to display instead of the client timezone.", @@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = { }, { name: "Date format", - description: "Format of the date displayed below the clock.", - values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.", + description: "Choose a common Day.js date format with a live example and its tokens.", + values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats", defaultValue: "dddd, MMMM D", }, { - name: "Custom time format", - description: "Format of the time displayed in the clock.", - values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.', - defaultValue: "-", + name: "Time format", + description: + "Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.", + values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A", + defaultValue: "HH:mm", }, { - name: "Custom date format", - description: "Format of the date displayed below the clock.", - values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.', - defaultValue: "-", + name: "Show weather", + description: "Show a compact weather summary from an independently configured location.", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Weather location", + description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.', + values: "A searched city or custom latitude and longitude", + defaultValue: "Brisbane", + }, + { + name: "Use Fahrenheit", + description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.', + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Animate weather icon", + description: "Animate the weather icon unless the device requests reduced motion.", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "World clock cities", + description: "Add, label, and order up to six IANA timezones for advanced view.", + values: "Up to six labeled IANA timezones", + defaultValue: "New York, Paris, and Tokyo", }, ], }, diff --git a/apps/docs/docs/widgets/countdown/index.mdx b/apps/docs/docs/widgets/countdown/index.mdx new file mode 100644 index 000000000..268e1b7ba --- /dev/null +++ b/apps/docs/docs/widgets/countdown/index.mdx @@ -0,0 +1,44 @@ +--- +title: "Countdowns" +description: "Tracks upcoming events and recurring anniversaries." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { countdownWidget } from "."; + + + +The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local +date and time, and select an IANA timezone. Events can be reordered by dragging them. + +The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on +the board. Advanced view uses that same event for its larger summary and preserves the configured order within its +upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar. + +One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its +configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year. +If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice, +the earlier occurrence is used. + +Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show +progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event. + +Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is +requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser +storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site +data resets them. Widget previews do not persist alert preferences. + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/countdown/index.ts b/apps/docs/docs/widgets/countdown/index.ts new file mode 100644 index 000000000..4e6d94674 --- /dev/null +++ b/apps/docs/docs/widgets/countdown/index.ts @@ -0,0 +1,31 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconHourglass } from "@tabler/icons-react"; + +export const countdownWidget: WidgetDefinition = { + icon: IconHourglass, + name: "Countdowns", + description: "Tracks upcoming events and recurring anniversaries.", + path: "../../widgets/countdown", + configuration: { + items: [ + { + name: "Events", + description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.", + values: "Up to 20 events", + defaultValue: "No events", + }, + { + name: "Show progress", + description: "Show progress from an optional start date, or between yearly occurrences.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show seconds", + description: "Keep seconds visible for every upcoming event, including events more than a day away.", + values: { type: "boolean" }, + defaultValue: "no", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/timer/index.mdx b/apps/docs/docs/widgets/timer/index.mdx new file mode 100644 index 000000000..e1dca36ae --- /dev/null +++ b/apps/docs/docs/widgets/timer/index.mdx @@ -0,0 +1,52 @@ +--- +title: "Timer / Pomodoro" +description: "Runs a simple timer or a configurable Pomodoro sequence." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { timerWidget } from "."; + + + +The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget +always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening +the board or returning to a background tab restores the correct remaining time instead of restarting the countdown. + +Timer mode repeats one configured duration. Pomodoro mode starts with a focus session, follows most completed focus +sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and +focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are +reconciled from their original deadlines after the board returns to the foreground. + +Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are +requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are +stored locally using the board and widget identifiers. They are separate for each browser or device, are available to +guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state. + +Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use +rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant +notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and +supplement the visible timer state rather than replacing it. + +When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the +board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at +300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border. +Compact controls also step down in size when the widget preview is narrow or short. + +Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them. +The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited +from the widget context menu. + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/timer/index.ts b/apps/docs/docs/widgets/timer/index.ts new file mode 100644 index 000000000..2874cabad --- /dev/null +++ b/apps/docs/docs/widgets/timer/index.ts @@ -0,0 +1,61 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconClockPlay } from "@tabler/icons-react"; + +export const timerWidget: WidgetDefinition = { + icon: IconClockPlay, + name: "Timer / Pomodoro", + description: "Runs a simple timer or a configurable Pomodoro sequence.", + path: "../../widgets/timer", + configuration: { + items: [ + { + name: "Mode", + description: "Run one reusable timer or a repeating focus and break sequence", + values: { type: "select", options: ["Timer", "Pomodoro"] }, + defaultValue: "Pomodoro", + }, + { + name: "Timer minutes", + description: "Duration of the reusable timer", + values: "1-1440 minutes", + defaultValue: "10", + }, + { + name: "Focus minutes", + description: "Duration of each Pomodoro focus session", + values: "1-180 minutes", + defaultValue: "25", + }, + { + name: "Short break minutes", + description: "Duration of breaks between most focus sessions", + values: "1-60 minutes", + defaultValue: "5", + }, + { + name: "Long break minutes", + description: "Duration of the break at the end of a Pomodoro cycle", + values: "1-180 minutes", + defaultValue: "15", + }, + { + name: "Focus sessions before a long break", + description: "Number of completed focus sessions in one Pomodoro cycle", + values: "1-12 sessions", + defaultValue: "4", + }, + { + name: "Automatically start breaks", + description: "Start the next short or long break when a focus session completes", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Automatically start focus sessions", + description: "Start the next focus session when a break completes", + values: { type: "boolean" }, + defaultValue: "no", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/today/index.mdx b/apps/docs/docs/widgets/today/index.mdx new file mode 100644 index 000000000..000861d20 --- /dev/null +++ b/apps/docs/docs/widgets/today/index.mdx @@ -0,0 +1,34 @@ +--- +title: "Today" +description: "Displays the current date and progress through the calendar." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { todayWidget } from "."; + + + +The Today widget presents the current local date without duplicating a live clock. It progressively adds the week +number, day of year, quarter, remaining days, and calendar progress as more space becomes available. + +The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow +your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count +unambiguous. + +The widget runs entirely in your browser and does not send date or location data to an external service. It follows +the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date. + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/today/index.ts b/apps/docs/docs/widgets/today/index.ts new file mode 100644 index 000000000..8922d4b6e --- /dev/null +++ b/apps/docs/docs/widgets/today/index.ts @@ -0,0 +1,43 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconCalendarWeek } from "@tabler/icons-react"; + +export const todayWidget: WidgetDefinition = { + icon: IconCalendarWeek, + name: "Today", + description: "Displays the current date and progress through the calendar.", + path: "../../widgets/today", + configuration: { + items: [ + { + name: "Week numbering", + description: "Use locale-specific week numbering or ISO 8601 week numbers", + values: { type: "select", options: ["Use locale rules", "ISO 8601"] }, + defaultValue: "Use locale rules", + }, + { + name: "Show week number", + description: "Display the current week number when the widget has room", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show day of year", + description: "Display the current day number within the year", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show quarter", + description: "Display the current calendar quarter", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show year progress", + description: "Display progress through the current year", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index f64f5d754..c03357f87 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -13,23 +13,21 @@ import { weatherWidget } from "."; -The Weather Widget uses your location to display the current, highest and lowest temperature. -In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch. -The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the -available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open -Meteo response. +The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start +with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and +temperature readable before removing secondary details or forecast days. -### Screenshots +Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and +daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour +temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for +assistive technologies. -import forecastWeather from "./img/forecast.png"; -import currentWeather from "./img/current.png"; -import advancedWeather from "./img/advanced-view.png"; +The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly +details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on +the standard desktop surface and fewer columns as the surface narrows. -
- Weather compact forecast - Weather compact current conditions - Weather advanced view with seven-day forecast details -
+Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's +reduced-motion preference always disables it. @@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png"; ### Data source -We use the Open Meteo API to fetch the weather and locations. +We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while +identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the +same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an +error indicator. + +Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to whitelist the api, please use the following URL: `https://open-meteo.com` diff --git a/apps/docs/docs/widgets/weather/index.tsx b/apps/docs/docs/widgets/weather/index.tsx index ff795f984..836b70ca5 100644 --- a/apps/docs/docs/widgets/weather/index.tsx +++ b/apps/docs/docs/widgets/weather/index.tsx @@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = { values: { type: "boolean", }, + defaultValue: "no", + }, + { + name: "Show humidity", + description: "Display humidity in current conditions, hourly details, and daily forecasts", + values: { + type: "boolean", + }, defaultValue: "yes", }, + { + name: "Animate weather icons", + description: "Animate condition icons unless the operating system requests reduced motion", + values: { + type: "boolean", + }, + defaultValue: "no", + }, { name: "Use imperial speed units", description: "Display wind speed in mph instead of km/h", @@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = { values: { type: "boolean", }, - defaultValue: "no", + defaultValue: "yes", }, { name: "Has forecast", @@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = { values: { type: "boolean", }, - defaultValue: "no", + defaultValue: "yes", }, { name: "Amount of forecast days", description: "Number of days to show in the forecast", values: "1-7", - defaultValue: "5", + defaultValue: "3", }, ], }, diff --git a/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx b/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx index da7e58a87..ae19cb0f8 100644 --- a/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx +++ b/apps/docs/src/components/pages/home/hero/widgets/weather-widget.tsx @@ -1,4 +1,4 @@ -import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react"; +import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react"; import { CommonWidgetProps, WidgetCard } from "./card"; import clsx from "clsx"; @@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => { return (
- - 10.8°C -
-
- - 5 km/h -
-
-
- - 13.5°C + +
+ 18.4°C + Paris
-
- - 6.4°C +
+
+
+ Mon + + 21° +
+
+ Tue + + 19° +
+
+ Wed + + 16°
diff --git a/packages/api/src/router/widgets/air-quality.ts b/packages/api/src/router/widgets/air-quality.ts new file mode 100644 index 000000000..78cad2f08 --- /dev/null +++ b/packages/api/src/router/widgets/air-quality.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +import { env } from "@homarr/common/env"; +import { airQualityRequestHandler } from "@homarr/request-handler/air-quality"; + +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const atLocationInput = z.object({ + longitude: z.number().min(-180).max(180), + latitude: z.number().min(-90).max(90), +}); + +export const airQualityRouter = createTRPCRouter({ + atLocation: publicProcedure.input(atLocationInput).query(async ({ input }) => { + if (env.NO_EXTERNAL_CONNECTION) return null; + + const handler = airQualityRequestHandler.handler(input); + return await handler.getDataAsync().then((result) => result.data); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index ba5987463..01edcbd3b 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -13,6 +13,7 @@ export const widgetRouter = createTRPCRouter({ bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), + airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)), smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)), diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts index 98416ee3c..f40ac030f 100644 --- a/packages/api/src/router/widgets/weather.ts +++ b/packages/api/src/router/widgets/weather.ts @@ -6,8 +6,8 @@ import { weatherRequestHandler } from "@homarr/request-handler/weather"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const atLocationInput = z.object({ - longitude: z.number(), - latitude: z.number(), + longitude: z.number().min(-180).max(180), + latitude: z.number().min(-90).max(90), }); export const weatherRouter = createTRPCRouter({ diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 92cc02d44..6af7bf14b 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -336,6 +336,10 @@ export type HomarrDocumentationPath = | "/docs/integrations/whats-up-docker" | "/docs/widgets/clock" | "/docs/widgets/weather" + | "/docs/widgets/today" + | "/docs/widgets/air-quality" + | "/docs/widgets/countdown" + | "/docs/widgets/timer" | "/docs/widgets/app" | "/docs/widgets/iframe" | "/docs/widgets/video" diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index dd3f02e37..2504dda24 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -3,6 +3,10 @@ import type { WidgetKind } from "../widget"; export const widgetDocSlugs: Record = { clock: "clock", weather: "weather", + today: "today", + airQuality: "air-quality", + countdown: "countdown", + timer: "timer", app: "app", iframe: "iframe", video: "video", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 90274f3d8..f3223a186 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -1,6 +1,10 @@ export const widgetKinds = [ "clock", "weather", + "today", + "airQuality", + "countdown", + "timer", "app", "iframe", "video", @@ -59,6 +63,10 @@ export const widgetKinds = [ export type WidgetKind = (typeof widgetKinds)[number]; export const widgetDefaultSizes: Partial> = { + today: { width: 2, height: 1 }, + airQuality: { width: 2, height: 1 }, + countdown: { width: 2, height: 1 }, + timer: { width: 2, height: 1 }, uptimeKuma: { width: 2, height: 3 }, audioStats: { width: 2, height: 2 }, paperlessNgx: { width: 2, height: 2 }, diff --git a/packages/request-handler/src/air-quality.spec.ts b/packages/request-handler/src/air-quality.spec.ts new file mode 100644 index 000000000..d2b174fa4 --- /dev/null +++ b/packages/request-handler/src/air-quality.spec.ts @@ -0,0 +1,101 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { airQualityRequestHandler } from "./air-quality"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const makeAirQualityResponse = () => { + const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"]; + const values = [10, 20, 30]; + const fields = [ + "european_aqi", + "us_aqi", + "uv_index", + "pm10", + "pm2_5", + "carbon_monoxide", + "nitrogen_dioxide", + "sulphur_dioxide", + "ozone", + "european_aqi_pm2_5", + "european_aqi_pm10", + "european_aqi_nitrogen_dioxide", + "european_aqi_ozone", + "european_aqi_sulphur_dioxide", + "us_aqi_pm2_5", + "us_aqi_pm10", + "us_aqi_nitrogen_dioxide", + "us_aqi_carbon_monoxide", + "us_aqi_ozone", + "us_aqi_sulphur_dioxide", + "alder_pollen", + "birch_pollen", + "grass_pollen", + "mugwort_pollen", + "olive_pollen", + "ragweed_pollen", + ] as const; + + return { + timezone: "Europe/Berlin", + utc_offset_seconds: 7200, + current: { + time: "2026-07-01T12:00", + ...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])), + }, + hourly: { + time: times, + ...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])), + }, + fields, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + airQualityRequestHandler.invalidateCache(); +}); + +describe("air quality request handler", () => { + it("requests and normalizes the full useful forecast field set", async () => { + const fixture = makeAirQualityResponse(); + mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture))); + + const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(); + const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(); + + expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z"); + expect(first.data.current.pollutants.pm2_5).toBe(12); + expect(first.data.current.pollen.ragweed).toBeNull(); + expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z"); + expect(first.data.daily).toEqual([ + { date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 }, + { date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 }, + ]); + expect(second.data).toStrictEqual(first.data); + expect(mockFetch).toHaveBeenCalledOnce(); + + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); + expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com"); + expect(requestUrl.searchParams.get("forecast_days")).toBe("7"); + expect(requestUrl.searchParams.get("timezone")).toBe("auto"); + expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5"); + expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide"); + expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen"); + }); + + it("rejects unsuccessful Open-Meteo responses", async () => { + mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 })); + + await expect( + airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(), + ).rejects.toThrow(); + }); +}); diff --git a/packages/request-handler/src/air-quality.ts b/packages/request-handler/src/air-quality.ts new file mode 100644 index 000000000..6a77a871a --- /dev/null +++ b/packages/request-handler/src/air-quality.ts @@ -0,0 +1,283 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import { z } from "zod"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; + +import { createWidgetRequestHandler } from "./lib/widget-request-handler"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const FORECAST_DAYS = 7; + +const requestedVariables = [ + "european_aqi", + "us_aqi", + "uv_index", + "pm10", + "pm2_5", + "carbon_monoxide", + "nitrogen_dioxide", + "sulphur_dioxide", + "ozone", + "european_aqi_pm2_5", + "european_aqi_pm10", + "european_aqi_nitrogen_dioxide", + "european_aqi_ozone", + "european_aqi_sulphur_dioxide", + "us_aqi_pm2_5", + "us_aqi_pm10", + "us_aqi_nitrogen_dioxide", + "us_aqi_carbon_monoxide", + "us_aqi_ozone", + "us_aqi_sulphur_dioxide", + "alder_pollen", + "birch_pollen", + "grass_pollen", + "mugwort_pollen", + "olive_pollen", + "ragweed_pollen", +] as const; + +export const airQualityRequestHandler = createWidgetRequestHandler({ + cacheTtlMs: 15 * 60 * 1000, + fallbackToStaleOnError: true, + staleIfErrorTtlMs: 24 * 60 * 60 * 1000, + async requestAsync(input: { latitude: number; longitude: number }) { + const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality"); + url.searchParams.set("latitude", input.latitude.toString()); + url.searchParams.set("longitude", input.longitude.toString()); + url.searchParams.set("current", requestedVariables.join(",")); + url.searchParams.set("hourly", requestedVariables.join(",")); + url.searchParams.set("forecast_days", FORECAST_DAYS.toString()); + url.searchParams.set("timezone", "auto"); + + const response = await withTimeoutAsync(async (signal) => { + return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); + }); + if (!response.ok) throw new ResponseError(response); + + const json: unknown = await response.json(); + const airQuality = await atLocationOutput.parseAsync(json); + const hourly = airQuality.hourly.time.map((value, index) => { + return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone)); + }); + + return { + timezone: airQuality.timezone, + utcOffsetSeconds: airQuality.utc_offset_seconds, + current: normalizePoint( + airQuality.current, + undefined, + toUtcInstant(airQuality.current.time, airQuality.timezone), + ), + hourly, + daily: getDailyMaximums(airQuality.hourly), + } satisfies AirQuality; + }, +}); + +const nullableNumber = z.number().nullable(); +const nullableNumberArray = z.array(nullableNumber); + +const currentValues = z.object({ + time: z.string(), + european_aqi: nullableNumber, + us_aqi: nullableNumber, + uv_index: nullableNumber, + pm10: nullableNumber, + pm2_5: nullableNumber, + carbon_monoxide: nullableNumber, + nitrogen_dioxide: nullableNumber, + sulphur_dioxide: nullableNumber, + ozone: nullableNumber, + european_aqi_pm2_5: nullableNumber, + european_aqi_pm10: nullableNumber, + european_aqi_nitrogen_dioxide: nullableNumber, + european_aqi_ozone: nullableNumber, + european_aqi_sulphur_dioxide: nullableNumber, + us_aqi_pm2_5: nullableNumber, + us_aqi_pm10: nullableNumber, + us_aqi_nitrogen_dioxide: nullableNumber, + us_aqi_carbon_monoxide: nullableNumber, + us_aqi_ozone: nullableNumber, + us_aqi_sulphur_dioxide: nullableNumber, + alder_pollen: nullableNumber, + birch_pollen: nullableNumber, + grass_pollen: nullableNumber, + mugwort_pollen: nullableNumber, + olive_pollen: nullableNumber, + ragweed_pollen: nullableNumber, +}); + +const hourlyValues = z.object({ + time: z.array(z.string()), + european_aqi: nullableNumberArray, + us_aqi: nullableNumberArray, + uv_index: nullableNumberArray, + pm10: nullableNumberArray, + pm2_5: nullableNumberArray, + carbon_monoxide: nullableNumberArray, + nitrogen_dioxide: nullableNumberArray, + sulphur_dioxide: nullableNumberArray, + ozone: nullableNumberArray, + european_aqi_pm2_5: nullableNumberArray, + european_aqi_pm10: nullableNumberArray, + european_aqi_nitrogen_dioxide: nullableNumberArray, + european_aqi_ozone: nullableNumberArray, + european_aqi_sulphur_dioxide: nullableNumberArray, + us_aqi_pm2_5: nullableNumberArray, + us_aqi_pm10: nullableNumberArray, + us_aqi_nitrogen_dioxide: nullableNumberArray, + us_aqi_carbon_monoxide: nullableNumberArray, + us_aqi_ozone: nullableNumberArray, + us_aqi_sulphur_dioxide: nullableNumberArray, + alder_pollen: nullableNumberArray, + birch_pollen: nullableNumberArray, + grass_pollen: nullableNumberArray, + mugwort_pollen: nullableNumberArray, + olive_pollen: nullableNumberArray, + ragweed_pollen: nullableNumberArray, +}); + +const atLocationOutput = z.object({ + timezone: z.string().min(1), + utc_offset_seconds: z.number(), + current: currentValues, + hourly: hourlyValues, +}); + +type CurrentValues = z.infer; +type HourlyValues = z.infer; + +const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit, index?: number) => { + const value = values[field]; + if (Array.isArray(value)) return value[index ?? 0] ?? null; + return value; +}; + +const normalizePoint = ( + values: CurrentValues | HourlyValues, + index: number | undefined, + observedAt: string, +): AirQualityPoint => ({ + observedAt, + europeanAqi: getValue(values, "european_aqi", index), + usAqi: getValue(values, "us_aqi", index), + uvIndex: getValue(values, "uv_index", index), + pollutants: { + pm2_5: getValue(values, "pm2_5", index), + pm10: getValue(values, "pm10", index), + ozone: getValue(values, "ozone", index), + nitrogenDioxide: getValue(values, "nitrogen_dioxide", index), + sulphurDioxide: getValue(values, "sulphur_dioxide", index), + carbonMonoxide: getValue(values, "carbon_monoxide", index), + }, + europeanSubIndices: { + pm2_5: getValue(values, "european_aqi_pm2_5", index), + pm10: getValue(values, "european_aqi_pm10", index), + ozone: getValue(values, "european_aqi_ozone", index), + nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index), + sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index), + carbonMonoxide: null, + }, + usSubIndices: { + pm2_5: getValue(values, "us_aqi_pm2_5", index), + pm10: getValue(values, "us_aqi_pm10", index), + ozone: getValue(values, "us_aqi_ozone", index), + nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index), + sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index), + carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index), + }, + pollen: { + alder: getValue(values, "alder_pollen", index), + birch: getValue(values, "birch_pollen", index), + grass: getValue(values, "grass_pollen", index), + mugwort: getValue(values, "mugwort_pollen", index), + olive: getValue(values, "olive_pollen", index), + ragweed: getValue(values, "ragweed_pollen", index), + }, +}); + +const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => { + const grouped = new Map(); + + hourly.time.forEach((time, index) => { + const date = time.slice(0, 10); + const existing = grouped.get(date) ?? { + date, + europeanAqiMax: null, + usAqiMax: null, + uvIndexMax: null, + pm2_5Max: null, + pm10Max: null, + }; + + existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]); + existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]); + existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]); + existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]); + existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]); + grouped.set(date, existing); + }); + + return Array.from(grouped.values()).slice(0, FORECAST_DAYS); +}; + +const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => { + if (candidate === null || candidate === undefined) return current; + if (current === null) return candidate; + return Math.max(current, candidate); +}; + +const toUtcInstant = (value: string, timeZone: string): string => { + const parsed = dayjs.tz(value, timeZone); + if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`); + return parsed.utc().toISOString(); +}; + +export interface AirQualityPollutants { + pm2_5: number | null; + pm10: number | null; + ozone: number | null; + nitrogenDioxide: number | null; + sulphurDioxide: number | null; + carbonMonoxide: number | null; +} + +export interface AirQualityPoint { + observedAt: string; + europeanAqi: number | null; + usAqi: number | null; + uvIndex: number | null; + pollutants: AirQualityPollutants; + europeanSubIndices: AirQualityPollutants; + usSubIndices: AirQualityPollutants; + pollen: { + alder: number | null; + birch: number | null; + grass: number | null; + mugwort: number | null; + olive: number | null; + ragweed: number | null; + }; +} + +export interface AirQuality { + timezone: string; + utcOffsetSeconds: number; + current: AirQualityPoint; + hourly: AirQualityPoint[]; + daily: { + date: string; + europeanAqiMax: number | null; + usAqiMax: number | null; + uvIndexMax: number | null; + pm2_5Max: number | null; + pm10Max: number | null; + }[]; +} diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts index b5273c87f..350826fe3 100644 --- a/packages/request-handler/src/lib/widget-request-handler.ts +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -2,11 +2,14 @@ import { createRequestHandler } from "./request-handler"; interface Options> { requestAsync: (input: TInput) => Promise; + cacheTtlMs?: number; + fallbackToStaleOnError?: boolean; + staleIfErrorTtlMs?: number; } export const createWidgetRequestHandler = >( options: Options, ) => { - const inner = createRequestHandler({ requestAsync: options.requestAsync }); + const inner = createRequestHandler(options); return inner; }; diff --git a/packages/request-handler/src/weather.spec.ts b/packages/request-handler/src/weather.spec.ts new file mode 100644 index 000000000..a5e8e767c --- /dev/null +++ b/packages/request-handler/src/weather.spec.ts @@ -0,0 +1,110 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { weatherRequestHandler } from "./weather"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const makeWeatherResponse = () => { + const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`); + const days = [ + "2026-06-30", + ...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`), + ]; + const hourlyValues = hours.map((_, index) => index); + const dailyValues = days.map((_, index) => index); + const sunrise = days.map((day) => `${day}T05:00`); + + return { + timezone: "Europe/Berlin", + utc_offset_seconds: 7200, + current: { + time: "2026-07-01T12:00", + temperature_2m: 24, + relative_humidity_2m: 55, + apparent_temperature: 25, + is_day: 1, + precipitation: 0, + weather_code: 1, + wind_speed_10m: 12, + wind_gusts_10m: 20, + }, + hourly: { + time: hours, + temperature_2m: hourlyValues, + apparent_temperature: hourlyValues, + precipitation_probability: hourlyValues, + precipitation: hourlyValues, + weather_code: hourlyValues, + relative_humidity_2m: hourlyValues, + wind_speed_10m: hourlyValues, + wind_gusts_10m: hourlyValues, + }, + daily: { + time: days, + weather_code: dailyValues, + temperature_2m_max: dailyValues, + temperature_2m_min: dailyValues, + apparent_temperature_max: dailyValues, + apparent_temperature_min: dailyValues, + precipitation_probability_max: dailyValues, + precipitation_sum: dailyValues, + relative_humidity_2m_mean: dailyValues, + wind_speed_10m_max: dailyValues, + wind_gusts_10m_max: dailyValues, + uv_index_max: dailyValues, + sunrise, + sunset: days.map((day) => `${day}T21:00`), + daylight_duration: dailyValues, + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + weatherRequestHandler.invalidateCache(); +}); + +describe("weather request handler", () => { + it("requests bounded useful forecasts and normalizes location times", async () => { + const response = makeWeatherResponse(); + response.hourly.temperature_2m = [null]; + response.daily.sunrise[1] = null; + mockFetch.mockResolvedValue(new Response(JSON.stringify(response))); + + const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(); + const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(); + + expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z"); + expect(first.data.hourly).toHaveLength(24); + expect(first.data.daily).toHaveLength(7); + expect(first.data.daily[0]?.date).toBe("2026-07-01"); + expect(first.data.hourly[0]?.temperature).toBeNull(); + expect(first.data.hourly[1]?.temperature).toBeNull(); + expect(first.data.daily[0]?.sunriseAt).toBeNull(); + expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z"); + expect(second.data).toStrictEqual(first.data); + expect(mockFetch).toHaveBeenCalledOnce(); + + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); + expect(requestUrl.searchParams.get("forecast_hours")).toBe("24"); + expect(requestUrl.searchParams.get("forecast_days")).toBe("7"); + expect(requestUrl.searchParams.has("past_days")).toBe(false); + expect(requestUrl.searchParams.get("timezone")).toBe("auto"); + expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature"); + expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability"); + expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max"); + }); + + it("rejects unsuccessful Open-Meteo responses", async () => { + mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 })); + + await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow(); + }); +}); diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index c2e5151ac..749880b22 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -1,73 +1,254 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; import { z } from "zod"; +import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; +dayjs.extend(utc); +dayjs.extend(timezone); + +const HOURLY_FORECAST_LENGTH = 24; +const DAILY_FORECAST_LENGTH = 7; + +const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise => { + const url = new URL("https://api.open-meteo.com/v1/forecast"); + url.searchParams.set("latitude", input.latitude.toString()); + url.searchParams.set("longitude", input.longitude.toString()); + url.searchParams.set( + "current", + [ + "temperature_2m", + "relative_humidity_2m", + "apparent_temperature", + "is_day", + "precipitation", + "weather_code", + "wind_speed_10m", + "wind_gusts_10m", + ].join(","), + ); + url.searchParams.set( + "hourly", + [ + "temperature_2m", + "apparent_temperature", + "precipitation_probability", + "precipitation", + "weather_code", + "relative_humidity_2m", + "wind_speed_10m", + "wind_gusts_10m", + ].join(","), + ); + url.searchParams.set( + "daily", + [ + "weather_code", + "temperature_2m_max", + "temperature_2m_min", + "apparent_temperature_max", + "apparent_temperature_min", + "precipitation_probability_max", + "precipitation_sum", + "relative_humidity_2m_mean", + "wind_speed_10m_max", + "wind_gusts_10m_max", + "uv_index_max", + "sunrise", + "sunset", + "daylight_duration", + ].join(","), + ); + url.searchParams.set("timezone", "auto"); + url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString()); + url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString()); + + const res = await withTimeoutAsync(async (signal) => { + return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); + }); + if (!res.ok) throw new ResponseError(res); + + const json: unknown = await res.json(); + const weather = await atLocationOutput.parseAsync(json); + const currentLocalDate = weather.current.time.slice(0, 10); + const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate); + const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex; + const dailyIndexes = weather.daily.time + .map((_, index) => index) + .slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH); + return { + timezone: weather.timezone, + utcOffsetSeconds: weather.utc_offset_seconds, + current: { + observedAt: toUtcInstant(weather.current.time, weather.timezone), + temperature: weather.current.temperature_2m, + apparentTemperature: weather.current.apparent_temperature, + relativeHumidity: weather.current.relative_humidity_2m, + precipitation: weather.current.precipitation, + weatherCode: weather.current.weather_code, + weathercode: weather.current.weather_code, + windSpeed: weather.current.wind_speed_10m, + windspeed: weather.current.wind_speed_10m, + windGusts: weather.current.wind_gusts_10m, + isDay: weather.current.is_day === 1, + }, + hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => { + return { + observedAt: toUtcInstant(value, weather.timezone), + temperature: weather.hourly.temperature_2m[index] ?? null, + apparentTemperature: weather.hourly.apparent_temperature[index] ?? null, + precipitationProbability: weather.hourly.precipitation_probability[index] ?? null, + precipitation: weather.hourly.precipitation[index] ?? null, + weatherCode: weather.hourly.weather_code[index] ?? null, + relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null, + windSpeed: weather.hourly.wind_speed_10m[index] ?? null, + windGusts: weather.hourly.wind_gusts_10m[index] ?? null, + }; + }), + daily: dailyIndexes.map((index) => { + const value = weather.daily.time[index] ?? currentLocalDate; + return { + date: value, + time: value, + weatherCode: weather.daily.weather_code[index] ?? 404, + maxTemperature: weather.daily.temperature_2m_max[index] ?? null, + maxTemp: weather.daily.temperature_2m_max[index] ?? undefined, + minTemperature: weather.daily.temperature_2m_min[index] ?? null, + minTemp: weather.daily.temperature_2m_min[index] ?? undefined, + maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null, + minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null, + precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null, + precipitation: weather.daily.precipitation_sum[index] ?? null, + averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null, + humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined, + maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null, + maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null, + uvIndex: weather.daily.uv_index_max[index] ?? null, + sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone), + sunrise: weather.daily.sunrise[index] ?? undefined, + sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone), + sunset: weather.daily.sunset[index] ?? undefined, + daylightDuration: weather.daily.daylight_duration[index] ?? null, + }; + }), + } satisfies Weather; +}; + export const weatherRequestHandler = createWidgetRequestHandler({ - async requestAsync(input: { latitude: number; longitude: number }) { - const res = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync( - `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean¤t_weather=true&timezone=auto`, - { signal }, - ); - }); - const json: unknown = await res.json(); - const weather = await atLocationOutput.parseAsync(json); - return { - current: weather.current_weather, - daily: weather.daily.time.map((value, index) => { - return { - time: value, - weatherCode: weather.daily.weathercode[index] ?? 404, - maxTemp: weather.daily.temperature_2m_max[index], - minTemp: weather.daily.temperature_2m_min[index], - sunrise: weather.daily.sunrise[index], - sunset: weather.daily.sunset[index], - maxWindSpeed: weather.daily.wind_speed_10m_max[index], - maxWindGusts: weather.daily.wind_gusts_10m_max[index], - humidity: weather.daily.relative_humidity_2m_mean[index], - }; - }), - } satisfies Weather; - }, + cacheTtlMs: 5 * 60 * 1000, + requestAsync: requestWeatherAsync, }); +const nullableNumberArray = z.array(z.number().nullable()); + const atLocationOutput = z.object({ - current_weather: z.object({ - weathercode: z.number(), - temperature: z.number(), - windspeed: z.number(), + timezone: z.string().min(1), + utc_offset_seconds: z.number(), + current: z.object({ + time: z.string(), + temperature_2m: z.number(), + relative_humidity_2m: z.number(), + apparent_temperature: z.number(), + is_day: z.number(), + precipitation: z.number(), + weather_code: z.number(), + wind_speed_10m: z.number(), + wind_gusts_10m: z.number(), + }), + hourly: z.object({ + time: z.array(z.string()), + temperature_2m: nullableNumberArray, + apparent_temperature: nullableNumberArray, + precipitation_probability: nullableNumberArray, + precipitation: nullableNumberArray, + weather_code: nullableNumberArray, + relative_humidity_2m: nullableNumberArray, + wind_speed_10m: nullableNumberArray, + wind_gusts_10m: nullableNumberArray, }), daily: z.object({ time: z.array(z.string()), - weathercode: z.array(z.number()), - temperature_2m_max: z.array(z.number()), - temperature_2m_min: z.array(z.number()), - sunrise: z.array(z.string()), - sunset: z.array(z.string()), - wind_speed_10m_max: z.array(z.number()), - wind_gusts_10m_max: z.array(z.number()), - relative_humidity_2m_mean: z.array(z.number()), + weather_code: nullableNumberArray, + temperature_2m_max: nullableNumberArray, + temperature_2m_min: nullableNumberArray, + apparent_temperature_max: nullableNumberArray, + apparent_temperature_min: nullableNumberArray, + precipitation_probability_max: nullableNumberArray, + precipitation_sum: nullableNumberArray, + relative_humidity_2m_mean: nullableNumberArray, + wind_speed_10m_max: nullableNumberArray, + wind_gusts_10m_max: nullableNumberArray, + uv_index_max: nullableNumberArray, + sunrise: z.array(z.string().nullable()), + sunset: z.array(z.string().nullable()), + daylight_duration: nullableNumberArray, }), }); +const toUtcInstant = (value: string, timeZone: string): string => { + const parsed = dayjs.tz(value, timeZone); + if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`); + return parsed.utc().toISOString(); +}; + +const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => { + if (!value) return null; + return toUtcInstant(value, timeZone); +}; + export interface Weather { + timezone: string; + utcOffsetSeconds: number; current: { - weathercode: number; temperature: number; + apparentTemperature: number; + relativeHumidity: number; + precipitation: number; + weatherCode: number; + weathercode: number; + windSpeed: number; windspeed: number; + windGusts: number; + isDay: boolean; + observedAt: string; }; + hourly: { + observedAt: string; + temperature: number | null; + apparentTemperature: number | null; + precipitationProbability: number | null; + precipitation: number | null; + weatherCode: number | null; + relativeHumidity: number | null; + windSpeed: number | null; + windGusts: number | null; + }[]; daily: { + date: string; time: string; weatherCode: number; + maxTemperature: number | null; maxTemp: number | undefined; + minTemperature: number | null; minTemp: number | undefined; - sunrise: string | undefined; - sunset: string | undefined; - maxWindSpeed: number | undefined; - maxWindGusts: number | undefined; + maxApparentTemperature: number | null; + minApparentTemperature: number | null; + precipitationProbability: number | null; + precipitation: number | null; + averageHumidity: number | null; humidity: number | undefined; + maxWindSpeed: number | null; + maxWindGusts: number | null; + uvIndex: number | null; + sunriseAt: string | null; + sunrise: string | undefined; + sunsetAt: string | null; + sunset: string | undefined; + daylightDuration: number | null; }[]; } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 31c64fe46..f68859071 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3153,9 +3153,6 @@ "label": "24-hour format", "description": "Use 24-hour format instead of 12-hour format" }, - "showSeconds": { - "label": "Display seconds" - }, "useCustomTimezone": { "label": "Use a fixed timezone" }, @@ -3167,16 +3164,12 @@ "label": "Show the date" }, "dateFormat": { - "label": "Date Format", - "description": "How the date should look like" + "label": "Date format", + "description": "Choose a Day.js preset. Each option shows a live example and its format tokens." }, "customTimeFormat": { - "label": "Custom time format", - "description": "Use ISO 8601 to format time (this will override other options)" - }, - "customDateFormat": { - "label": "Custom date format", - "description": "Use ISO 8601 to format date (this will override other options)" + "label": "Time format", + "description": "Choose a Day.js preset. The selected format controls 12/24-hour display and whether the clock updates every second." }, "showWeather": { "label": "Show weather", @@ -3187,7 +3180,298 @@ }, "isWeatherFormatFahrenheit": { "label": "Use Fahrenheit" + }, + "animateWeatherIcon": { + "label": "Animate weather icon", + "description": "Animate the weather icon. Disabled by default and always disabled when reduced motion is requested." + }, + "worldClockCities": { + "label": "World clock cities", + "description": "Add and order up to six cities for advanced view." } + }, + "timezoneList": { + "timezone": "Timezone", + "cityLabel": "City label", + "searchPlaceholder": "Search timezones", + "noResults": "No timezones found", + "unsupportedOption": "Unsupported: {timeZone}", + "add": "Add city", + "remove": "Remove {city}", + "reorder": "Reorder {city}", + "limit": "{count} of {maximum} cities configured" + }, + "worldClock": { + "primary": "Primary clock", + "local": "Local time", + "today": "Today", + "tomorrow": "Tomorrow", + "yesterday": "Yesterday", + "noSecondaryCities": "Add cities in widget options to compare them here.", + "unsupportedPrimaryTitle": "Primary timezone is unavailable", + "unsupportedPrimary": "{timeZone} is not supported in this browser. Showing UTC as a clearly marked fallback.", + "unsupportedTimeZone": "Unsupported timezone: {timeZone}", + "accessibleTime": "{city}: {time}, {date}, {timeZone}", + "advancedLabel": "Advanced world clock", + "cities": "World clocks", + "cityCount": "{count} timezones", + "timeBarLabel": "24-hour time-of-day position for {city}", + "dayProgress": "{percent}% elapsed · {hours}h {minutes}m remaining", + "phaseLegend": "Time-of-day legend", + "phase": { + "night": "Night", + "dawn": "Dawn", + "day": "Day", + "dusk": "Dusk" + } + } + }, + "airQuality": { + "name": "Air Quality & UV", + "description": "Displays air quality, ultraviolet index, pollutants and pollen for a location.", + "option": { + "location": { + "label": "Air quality location" + }, + "aqiStandard": { + "label": "AQI standard", + "options": { + "auto": "Automatic for locale", + "european": "European AQI", + "us": "US AQI" + } + }, + "showUv": { + "label": "Show UV index" + }, + "showPollutants": { + "label": "Show pollutant concentrations" + }, + "showPollen": { + "label": "Show pollen concentrations" + } + }, + "category": { + "good": "Good", + "fair": "Fair", + "moderate": "Moderate", + "unhealthySensitive": "Unhealthy for sensitive groups", + "unhealthy": "Unhealthy", + "poor": "Poor", + "veryPoor": "Very poor", + "veryUnhealthy": "Very unhealthy", + "extremelyPoor": "Extremely poor", + "hazardous": "Hazardous", + "unknown": "No AQI reading" + }, + "uv": { + "label": "UV index", + "category": { + "low": "Low", + "moderate": "Moderate", + "high": "High", + "veryHigh": "Very high", + "extreme": "Extreme", + "unknown": "No reading" + } + }, + "dominantPollutant": "Dominant pollutant", + "strongestPollen": "Highest pollen", + "pollutant": { + "pm2_5": "PM2.5", + "pm10": "PM10", + "ozone": "Ozone", + "nitrogenDioxide": "Nitrogen dioxide", + "sulphurDioxide": "Sulphur dioxide", + "carbonMonoxide": "Carbon monoxide" + }, + "pollen": { + "alder": "Alder", + "birch": "Birch", + "grass": "Grass", + "mugwort": "Mugwort", + "olive": "Olive", + "ragweed": "Ragweed" + }, + "compact": { + "sixHourTrend": "AQI trend for the next six hours" + }, + "advanced": { + "observedAt": "Updated at {time}", + "next24Hours": "Next 24 hours", + "pollutants": "Pollutant concentrations", + "pollen": "Pollen concentrations", + "sevenDayForecast": "7-day maximum forecast", + "aqiValue": "AQI {value}", + "uvValue": "UV {value}", + "uvShort": "UV {value}", + "noForecast": "No forecast data available" + } + }, + "countdown": { + "name": "Countdowns", + "description": "Tracks upcoming events and recurring anniversaries.", + "option": { + "events": { + "label": "Events", + "description": "Add and order up to 20 one-time or yearly countdowns. Dates are entered in the selected timezone." + }, + "showProgress": { + "label": "Show progress" + }, + "showSeconds": { + "label": "Show seconds", + "description": "Keep seconds visible for every upcoming event." + } + }, + "dateTimeEventList": { + "newEvent": "New event", + "add": "Add event", + "remove": "Remove {event}", + "reorder": "Reorder {event}", + "eventLabel": "Event label", + "timezone": "Timezone", + "unsupportedOption": "Unsupported: {timeZone}", + "recurrence": "Repeats", + "recurrenceOption": { + "none": "Does not repeat", + "yearly": "Every year" + }, + "target": "Event date and time", + "trackProgress": "Track progress from a start date", + "start": "Progress start date and time", + "confirmDateTime": "Confirm date and time", + "limit": "{count} of {maximum} events configured" + }, + "empty": { + "title": "No countdowns yet", + "description": "Add an event in widget options to start counting down." + }, + "completed": "Completed", + "yearly": "Yearly", + "progress": "Event progress", + "upcoming": "Upcoming", + "completedEvents": "Completed events", + "invalid": { + "title": "Some events cannot be displayed", + "description": "{count, plural, one {One event has an invalid date or unsupported timezone.} other {# events have an invalid date or unsupported timezone.}}", + "value": "Unavailable" + }, + "alert": { + "notifications": "Browser notifications", + "permissionDenied": "Notifications are blocked in this browser.", + "reached": "The countdown has reached its target." + } + }, + "timer": { + "name": "Timer / Pomodoro", + "description": "Runs a simple timer or a configurable Pomodoro sequence.", + "option": { + "mode": { + "label": "Mode", + "options": { + "timer": "Timer", + "pomodoro": "Pomodoro" + } + }, + "timerMinutes": { + "label": "Timer minutes" + }, + "focusMinutes": { + "label": "Focus minutes" + }, + "shortBreakMinutes": { + "label": "Short break minutes" + }, + "longBreakMinutes": { + "label": "Long break minutes" + }, + "sessionsBeforeLongBreak": { + "label": "Focus sessions before a long break" + }, + "autoStartBreaks": { + "label": "Automatically start breaks" + }, + "autoStartFocus": { + "label": "Automatically start focus sessions" + } + }, + "phase": { + "timer": "Timer", + "focus": "Focus", + "shortBreak": "Short break", + "longBreak": "Long break" + }, + "action": { + "start": "Start", + "pause": "Pause", + "reset": "Reset" + }, + "attention": { + "timerComplete": "Timer complete", + "phaseReady": "{phase} ready" + }, + "progress": "Timer progress: {progress}%", + "compact": { + "completedFocus": "{count, plural, =0 {No focus sessions completed} one {# focus session completed} other {# focus sessions completed}}" + }, + "alert": { + "title": "Timer complete", + "completed": "{phase} completed." + }, + "advanced": { + "sequence": "Pomodoro sequence", + "focusSession": "Focus {session}", + "settings": "Permanent settings", + "settingsHint": "Use the widget context menu to edit these board settings.", + "localAlerts": "Local alerts", + "notifications": "Browser notifications", + "sound": "Timer sounds", + "localAlertsHint": "Timer sounds are enabled by default. Sounds and notifications apply only to this widget in this browser.", + "notificationsDenied": "Notifications are blocked in this browser." + } + }, + "today": { + "name": "Today", + "description": "Displays the current date and progress through the calendar.", + "option": { + "weekConvention": { + "label": "Week numbering", + "options": { + "locale": "Use locale rules", + "iso": "ISO 8601" + } + }, + "showWeekNumber": { + "label": "Show week number" + }, + "showDayOfYear": { + "label": "Show day of year" + }, + "showQuarter": { + "label": "Show quarter" + }, + "showYearProgress": { + "label": "Show year progress" + } + }, + "weekNumber": "Week {week}", + "dayOfYear": "Day {day} of {total}", + "quarter": "Quarter {quarter}", + "daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}", + "weekProgressLabel": "Week progress", + "monthProgressLabel": "Month progress", + "yearProgressLabel": "Year progress", + "yearProgress": "Year progress: {progress}%", + "weekConvention": { + "locale": "Week numbers follow your locale", + "iso": "ISO 8601 week numbers" + }, + "advanced": { + "week": "Week number", + "dayOfYear": "Day of year", + "quarter": "Quarter", + "daysRemaining": "Days after today" } }, "minecraftServerStatus": { @@ -3583,6 +3867,13 @@ "disableTemperatureDecimals": { "label": "Disable temperature decimals" }, + "animateIcons": { + "label": "Animate weather icons" + }, + "showHumidity": { + "label": "Show humidity", + "description": "Display humidity in current conditions, hourly details and daily forecasts" + }, "showCurrentWindSpeed": { "label": "Show current wind speed", "description": "Only on current weather" @@ -3636,11 +3927,40 @@ "detailsFor": "Show weather details for {date}", "advanced": { "forecast": "7-day forecast", + "hourlyForecast": "Next 24 hours", "day": "Day", + "time": "Time", "conditions": "Conditions", - "temperature": "High / low", + "temperature": "Temperature", + "feelsLike": "Feels like {temperature}", + "feelsLikeLabel": "Feels like", + "feelsLikeRange": "Feels like: {maximum} / {minimum}", + "highLow": "High {maximum} · low {minimum}", + "observedAt": "Updated at {time}", "humidity": "Humidity", + "averageHumidity": "Average humidity: {humidity}%", + "currentHumidity": "Current humidity", "wind": "Wind / gusts ({unit})", + "currentWind": "Current wind / gusts", + "windValue": "{speed} / {gusts} {unit}", + "windDetail": "Max wind / gusts: {speed} / {gusts} {unit}", + "nextHourPrecipitation": "Precipitation next hour", + "precipitationChance": "Precipitation chance", + "precipitationValue": "{probability}% · {amount} mm", + "precipitationDetail": "Precipitation: {probability}% · {amount} mm", + "todayUv": "Today's max UV index", + "uvIndex": "Max UV index: {value}", + "daylight": "Daylight: {duration}", + "hourlyTooltip": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {humidity}% humidity · {speed} {speedUnit} wind", + "hourlyTooltipWithoutHumidity": "Feels like {feelsLike}{temperatureUnit} · {amount} mm · {speed} {speedUnit} wind", + "detailLabel": { + "feelsLike": "Feels like", + "precipitation": "Precipitation", + "humidity": "Average humidity", + "wind": "Max wind / gusts", + "uv": "Max UV index", + "daylight": "Daylight" + }, "sun": "Sunrise / sunset", "noForecast": "No forecast data available" } diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 0bf3bc8cb..034197e44 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -65,6 +65,7 @@ "@mantine/dates": "catalog:", "@mantine/form": "catalog:", "@mantine/hooks": "catalog:", + "@rexa-developer/tiks": "catalog:", "@tabler/icons-react": "catalog:", "@tiptap/extension-color": "catalog:", "@tiptap/extension-highlight": "catalog:", diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index e5f439dde..0e21ee59f 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -3,6 +3,7 @@ import { WidgetAnchorNoteInput } from "./widget-anchor-note-input"; import { WidgetAppInput } from "./widget-app-input"; import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input"; import { WidgetCustomWidgetConfigurationInput } from "./widget-custom-widget-configuration-input"; +import { WidgetDateTimeEventListInput } from "./widget-date-time-event-list-input"; import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input"; import { WidgetDynamicMultiSelectInput } from "./widget-dynamic-multiselect-input"; import { WidgetLocationInput } from "./widget-location-input"; @@ -15,6 +16,7 @@ import { WidgetSliderInput } from "./widget-slider-input"; import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input"; import { WidgetSwitchInput } from "./widget-switch-input"; import { WidgetTextInput } from "./widget-text-input"; +import { WidgetTimezoneListInput } from "./widget-timezone-list-input"; import { WidgetUmamiEventInput } from "./widget-umami-event-input"; import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input"; import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input"; @@ -26,6 +28,8 @@ const mapping = { internal: WidgetInternalInput, anchorNote: WidgetAnchorNoteInput, text: WidgetTextInput, + dateTimeEventList: WidgetDateTimeEventListInput, + timezoneList: WidgetTimezoneListInput, location: WidgetLocationInput, multiSelect: WidgetMultiSelectInput, multiText: WidgetMultiTextInput, diff --git a/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx b/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx new file mode 100644 index 000000000..0e837f211 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-date-time-event-list-input.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Switch, Text, TextInput } from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react"; +import dayjs from "dayjs"; + +import { createId } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { formatWallDateTime, resolveWallDateTimeString } from "../countdown/recurrence"; +import type { DateTimeEventRecurrence, OptionDateTimeEvent } from "../options"; +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetDateTimeEventListInput = ({ + property, + kind, + options, +}: CommonWidgetInputProps<"dateTimeEventList">) => { + const t = useWidgetInputTranslation(kind, property); + const tEditor = useEventListTranslation(kind); + const form = useFormContext(); + const fieldPath = `options.${property}`; + const currentValue = form.values.options[property]; + const values = Array.isArray(currentValue) ? (currentValue as OptionDateTimeEvent[]) : options.defaultValue; + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + useEffect(() => { + if (Array.isArray(currentValue)) return; + form.setFieldValue(fieldPath, options.defaultValue); + }, [currentValue, fieldPath, form, options.defaultValue]); + + const setValues = (nextValues: OptionDateTimeEvent[]) => form.setFieldValue(fieldPath, nextValues); + const addEvent = () => { + if (values.length >= options.maxValues) return; + const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const target = dayjs().add(1, "day").minute(0).second(0).millisecond(0); + setValues([ + ...values, + { + id: createId(), + label: tEditor("newEvent"), + targetUtc: target.toISOString(), + timeZone: localTimeZone, + recurrence: "none", + }, + ]); + }; + + return ( +
+ + {options.withDescription && ( + + {t("description")} + + )} + + + + { + if (!over || active.id === over.id) return; + const activeIndex = values.findIndex(({ id }) => id === active.id); + const overIndex = values.findIndex(({ id }) => id === over.id); + if (activeIndex < 0 || overIndex < 0) return; + setValues(arrayMove(values, activeIndex, overIndex)); + }} + > + id)} strategy={verticalListSortingStrategy}> + + {values.map((event) => ( + + setValues(values.map((candidate) => (candidate.id === event.id ? nextEvent : candidate))) + } + onRemove={() => setValues(values.filter((candidate) => candidate.id !== event.id))} + /> + ))} + + + + + + {tEditor("limit", { count: values.length, maximum: options.maxValues })} + + +
+ ); +}; + +interface SortableEventProps { + kind: WidgetKind; + event: OptionDateTimeEvent; + timeZoneOptions: readonly { value: string; label: string }[]; + onChange: (event: OptionDateTimeEvent) => void; + onRemove: () => void; +} + +const SortableEvent = ({ kind, event, timeZoneOptions, onChange, onRemove }: SortableEventProps) => { + const t = useEventListTranslation(kind); + const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ + id: event.id, + }); + const style = { + transform: transform ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` : undefined, + transition, + opacity: isDragging ? 0.6 : undefined, + zIndex: isDragging ? 1 : undefined, + }; + const hasCurrentTimeZone = timeZoneOptions.some(({ value }) => value === event.timeZone); + const selectableTimeZones = hasCurrentTimeZone + ? timeZoneOptions + : [{ value: event.timeZone, label: t("unsupportedOption", { timeZone: event.timeZone }) }, ...timeZoneOptions]; + const targetValue = formatWallDateTime(event.targetUtc, event.timeZone); + const startValue = event.startUtc ? formatWallDateTime(event.startUtc, event.timeZone) : null; + + const changeTimeZone = (timeZone: string) => { + const target = resolveWallDateTimeString(targetValue, timeZone); + const start = startValue ? resolveWallDateTimeString(startValue, timeZone) : null; + onChange({ + ...event, + timeZone, + targetUtc: target?.toISOString() ?? event.targetUtc, + startUtc: event.startUtc ? (start?.toISOString() ?? event.startUtc) : undefined, + }); + }; + + const updateWallDateTime = (value: string | null, field: "targetUtc" | "startUtc") => { + if (!value) return; + const resolved = resolveWallDateTimeString(value, event.timeZone); + if (!resolved) return; + onChange({ ...event, [field]: resolved.toISOString() }); + }; + + return ( + + + + + + + onChange({ ...event, label: changeEvent.currentTarget.value })} + /> + + + + + + + ({ value, label: t(`recurrenceOption.${value}`) }))} + onChange={(value) => { + if (value) onChange({ ...event, recurrence: value as DateTimeEventRecurrence }); + }} + /> + + + updateWallDateTime(value, "targetUtc")} + confirmLabel={t("confirmDateTime")} + /> + + {event.recurrence === "none" && ( + <> + { + if (!changeEvent.currentTarget.checked) { + const nextEvent = { ...event }; + delete nextEvent.startUtc; + onChange(nextEvent); + return; + } + onChange({ ...event, startUtc: new Date().toISOString() }); + }} + /> + {event.startUtc && ( + updateWallDateTime(value, "startUtc")} + confirmLabel={t("confirmDateTime")} + /> + )} + + )} + + + ); +}; + +interface ConfirmedDateTimePickerProps { + label: string; + value: string | null; + confirmLabel: string; + onChange: (value: string | null) => void; +} + +const ConfirmedDateTimePicker = ({ label, value, confirmLabel, onChange }: ConfirmedDateTimePickerProps) => { + const [draftValue, setDraftValue] = useState(value); + + useEffect(() => setDraftValue(value), [value]); + + const resetDraft = () => setDraftValue(value); + const confirmDraft = () => { + if (!draftValue) return; + onChange(draftValue); + }; + + return ( + + ); +}; + +type EventListTranslationKey = + | "newEvent" + | "add" + | "remove" + | "reorder" + | "eventLabel" + | "timezone" + | "unsupportedOption" + | "recurrence" + | "recurrenceOption.none" + | "recurrenceOption.yearly" + | "target" + | "trackProgress" + | "start" + | "confirmDateTime" + | "limit"; + +type EventListTranslation = (key: EventListTranslationKey, values?: Record) => string; + +const useEventListTranslation = (kind: WidgetKind) => + useScopedI18n(`widget.${kind}.dateTimeEventList` as never) as unknown as EventListTranslation; diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx index 1b1ffd645..6de7a36a1 100644 --- a/packages/widgets/src/_inputs/widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-select-input.tsx @@ -72,6 +72,7 @@ export const WidgetSelectInput = ({ property, kind, options }: CommonWidgetInput ); }} description={options.withDescription ? tWidget("description") : undefined} + placeholder={options.withPlaceholder ? tWidget("placeholder") : undefined} searchable={options.searchable} {...inputProps} /> diff --git a/packages/widgets/src/_inputs/widget-timezone-list-input.tsx b/packages/widgets/src/_inputs/widget-timezone-list-input.tsx new file mode 100644 index 000000000..27f5a1ee8 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-timezone-list-input.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { ActionIcon, Button, Card, Fieldset, Group, Select, Stack, Text, TextInput } from "@mantine/core"; +import { IconGripVertical, IconPlus, IconTrash } from "@tabler/icons-react"; + +import { createId } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { OptionTimezone } from "../options"; +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetTimezoneListInput = ({ property, kind, options }: CommonWidgetInputProps<"timezoneList">) => { + const t = useWidgetInputTranslation(kind, property); + const tEditor = useTimezoneListTranslation(kind); + const form = useFormContext(); + const fieldPath = `options.${property}`; + const currentValue = form.values.options[property]; + const values = Array.isArray(currentValue) ? (currentValue as OptionTimezone[]) : options.defaultValue; + const [selectedTimeZone, setSelectedTimeZone] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + useEffect(() => { + if (Array.isArray(currentValue)) return; + form.setFieldValue(fieldPath, options.defaultValue); + }, [currentValue, fieldPath, form, options.defaultValue]); + + const usedTimeZones = useMemo(() => new Set(values.map(({ timeZone }) => timeZone)), [values]); + const availableOptions = options.timeZoneOptions.map((option) => ({ + ...option, + disabled: usedTimeZones.has(option.value), + })); + const isFull = values.length >= options.maxValues; + + const setValues = (nextValues: OptionTimezone[]) => form.setFieldValue(fieldPath, nextValues); + const addCity = (city: OptionTimezone) => { + if (isFull || usedTimeZones.has(city.timeZone)) return; + setValues([...values, city]); + }; + const addSelectedTimeZone = () => { + if (!selectedTimeZone) return; + const option = options.timeZoneOptions.find(({ value }) => value === selectedTimeZone); + if (!option) return; + const label = option.label.replace(` (${option.value})`, ""); + addCity({ id: createId(), label, timeZone: option.value }); + setSelectedTimeZone(null); + }; + + return ( +
+ + {options.withDescription && ( + + {t("description")} + + )} + + + ({ + ...option, + disabled: option.value !== city.timeZone && usedTimeZones.has(option.value), + }))} + value={city.timeZone} + onChange={(timeZone) => { + if (timeZone) onChange({ ...city, timeZone }); + }} + /> + + + + + + ); +}; + +type TimezoneListTranslationKey = + | "timezone" + | "cityLabel" + | "searchPlaceholder" + | "noResults" + | "unsupportedOption" + | "add" + | "remove" + | "reorder" + | "limit"; + +type TimezoneListTranslation = (key: TimezoneListTranslationKey, values?: Record) => string; + +const useTimezoneListTranslation = (kind: WidgetKind) => + useScopedI18n(`widget.${kind}.timezoneList` as never) as unknown as TimezoneListTranslation; diff --git a/packages/widgets/src/air-quality/component.tsx b/packages/widgets/src/air-quality/component.tsx new file mode 100644 index 000000000..b891f6d94 --- /dev/null +++ b/packages/widgets/src/air-quality/component.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useId } from "react"; +import { CompositeChart, Sparkline } from "@mantine/charts"; +import { Box, Group, Paper, ScrollArea, SimpleGrid, Stack, Text, VisuallyHidden } from "@mantine/core"; +import { IconMapPin } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import { WidgetEmptyState } from "../common/empty-state"; +import { formatLocalizedDate, formatLocalizedTime } from "../common/locale"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; +import type { WidgetComponentProps } from "../definition"; +import type { AirQualityData, PollutantKey, PollenKey } from "./display"; +import { + getAqiCategory, + getAqiColor, + getAqiStandard, + getAqiValue, + getCompactAirQualityLayout, + getDominantPollutant, + getStrongestPollen, + getUpcomingHours, + getUvCategory, +} from "./display"; + +export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) { + const t = useScopedI18n("widget.airQuality"); + const query = clientApi.widget.airQuality.atLocation.useQuery({ + latitude: options.location.latitude, + longitude: options.location.longitude, + }); + const airQuality = getUsableWidgetQueryData(query) as AirQualityData | null | undefined; + + if (isInitialWidgetQueryPending(query)) return ; + if (!airQuality) return ; + + return ( + + {displayMode === "advanced" ? ( + + ) : ( + + )} + {query.error && ( + + + + )} + + ); +} + +interface AirQualityViewProps { + airQuality: AirQualityData; + options: WidgetComponentProps<"airQuality">["options"]; +} + +const CompactAirQuality = ({ + airQuality, + height, + options, + width, +}: AirQualityViewProps & { height: number; width: number }) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.airQuality"); + const layout = getCompactAirQualityLayout(width, height); + const standard = getAqiStandard(options.aqiStandard, locale); + const value = getAqiValue(airQuality.current, standard); + const category = getAqiCategory(value, standard); + const dominant = getDominantPollutant(airQuality.current, standard); + const strongestPollen = getStrongestPollen(airQuality.current); + const labels = useAirQualityLabels(); + const sixHours = getUpcomingHours(airQuality, 6) + .map((hour) => getAqiValue(hour, standard)) + .filter((hour): hour is number => hour !== null); + + return ( + + + + {formatAqi(value, locale)} + + + + {t(`category.${category}`)} + + + + + {!layout.tiny && ( + + {options.showUv && ( + + )} + {options.showPollutants && dominant && ( + + )} + + )} + + {!layout.tiny && layout.showParticulatesAndPollen && ( + + {options.showPollutants && ( + <> + + + + )} + {options.showPollen && strongestPollen && ( + + )} + + )} + + {!layout.tiny && layout.showSparkline && sixHours.length > 1 && ( + + + {t("compact.sixHourTrend")} + + )} + + ); +}; + +const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps & { width: number }) => { + const chartId = useId(); + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.airQuality"); + const standard = getAqiStandard(options.aqiStandard, locale); + const value = getAqiValue(airQuality.current, standard); + const category = getAqiCategory(value, standard); + const labels = useAirQualityLabels(); + const upcoming = getUpcomingHours(airQuality, 24); + const chartData = upcoming.map((hour) => ({ + observedAt: hour.observedAt, + aqi: getAqiValue(hour, standard) ?? Number.NaN, + uv: hour.uvIndex ?? Number.NaN, + })); + const series = [ + { name: "aqi", label: "AQI", color: getAqiColor(category), type: "line" as const }, + ...(options.showUv + ? [{ name: "uv", label: t("uv.label"), color: "yellow.7", type: "line" as const, yAxisId: "right" as const }] + : []), + ]; + + return ( + + + + + + {formatAqi(value, locale)} + + {t(`category.${category}`)} + + + + + + {options.location.name} + + + + {t("advanced.observedAt", { + time: formatLocalizedTime(airQuality.current.observedAt, locale, { timeZone: airQuality.timezone }), + })} + + + + + + + {options.showUv && ( + + )} + {options.showPollutants && ( + <> + + + + )} + + + = 800 ? 2 : 1} spacing="md"> + + + {t("advanced.next24Hours")} + + {chartData.length > 0 ? ( + <> + + + formatLocalizedTime(time, locale, { timeZone: airQuality.timezone }), + }} + /> + + +
    + {upcoming.map((hour) => ( +
  • + {formatLocalizedTime(hour.observedAt, locale, { timeZone: airQuality.timezone })}:{" "} + {t("advanced.aqiValue", { + value: formatAqi(getAqiValue(hour, standard), locale), + })} + {options.showUv + ? `, ${t("advanced.uvValue", { value: formatDecimal(hour.uvIndex, locale) })}` + : ""} +
  • + ))} +
+
+ + ) : ( + + {t("advanced.noForecast")} + + )} +
+ + + {options.showPollutants && ( + + + {t("advanced.pollutants")} + + + {pollutantKeys.map((key) => ( + + ))} + + + )} + + {options.showPollen && hasAvailableValue(airQuality.current.pollen) && ( + + + {t("advanced.pollen")} + + + {pollenKeys.map((key) => { + const pollen = airQuality.current.pollen[key]; + if (pollen === null) return null; + return ; + })} + + + )} + +
+ + + + {t("advanced.sevenDayForecast")} + + + {airQuality.daily.map((day) => { + const dailyAqi = standard === "us" ? day.usAqiMax : day.europeanAqiMax; + return ( + + + {formatLocalizedDate(day.date, locale, { weekday: "short" })} + + + {formatAqi(dailyAqi, locale)} + + {options.showUv && ( + {t("advanced.uvShort", { value: formatDecimal(day.uvIndexMax, locale) })} + )} + + ); + })} + + +
+
+ ); +}; + +const MetricCard = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + +const MetricLine = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + +const pollutantKeys: PollutantKey[] = ["pm2_5", "pm10", "ozone", "nitrogenDioxide", "sulphurDioxide", "carbonMonoxide"]; +const pollenKeys: PollenKey[] = ["alder", "birch", "grass", "mugwort", "olive", "ragweed"]; + +const useAirQualityLabels = () => { + const t = useScopedI18n("widget.airQuality"); + return { + pollutants: { + pm2_5: t("pollutant.pm2_5"), + pm10: t("pollutant.pm10"), + ozone: t("pollutant.ozone"), + nitrogenDioxide: t("pollutant.nitrogenDioxide"), + sulphurDioxide: t("pollutant.sulphurDioxide"), + carbonMonoxide: t("pollutant.carbonMonoxide"), + } satisfies Record, + pollen: { + alder: t("pollen.alder"), + birch: t("pollen.birch"), + grass: t("pollen.grass"), + mugwort: t("pollen.mugwort"), + olive: t("pollen.olive"), + ragweed: t("pollen.ragweed"), + } satisfies Record, + }; +}; + +const formatAqi = (value: number | null, locale: string) => { + if (value === null) return "—"; + return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(value); +}; + +const formatDecimal = (value: number | null, locale: string) => { + if (value === null) return "—"; + return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); +}; + +const formatConcentration = (value: number | null, locale: string) => `${formatDecimal(value, locale)} µg/m³`; +const formatPollen = (value: number | null, locale: string) => `${formatDecimal(value, locale)} grains/m³`; +const hasAvailableValue = (values: Record) => + Object.values(values).some((value) => value !== null); diff --git a/packages/widgets/src/air-quality/display.spec.ts b/packages/widgets/src/air-quality/display.spec.ts new file mode 100644 index 000000000..e97243f7d --- /dev/null +++ b/packages/widgets/src/air-quality/display.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + getAqiCategory, + getAqiStandard, + getCompactAirQualityLayout, + getDominantPollutant, + getStrongestPollen, +} from "./display"; + +const point = { + observedAt: "2026-07-01T10:00:00.000Z", + europeanAqi: 42, + usAqi: 60, + uvIndex: 4, + pollutants: { pm2_5: 12, pm10: 20, ozone: 30, nitrogenDioxide: 4, sulphurDioxide: 3, carbonMonoxide: 100 }, + europeanSubIndices: { + pm2_5: 20, + pm10: 31, + ozone: 42, + nitrogenDioxide: 10, + sulphurDioxide: 5, + carbonMonoxide: null, + }, + usSubIndices: { + pm2_5: 50, + pm10: 40, + ozone: 30, + nitrogenDioxide: 20, + sulphurDioxide: 10, + carbonMonoxide: 60, + }, + pollen: { alder: null, birch: 12, grass: 20, mugwort: 5, olive: null, ragweed: 2 }, +}; + +describe("air quality display", () => { + it("uses the US scale only for US locales in automatic mode", () => { + expect(getAqiStandard("auto", "en-US")).toBe("us"); + expect(getAqiStandard("auto", "fr-FR")).toBe("european"); + expect(getAqiStandard("auto", "en")).toBe("european"); + expect(getAqiStandard("us", "fr-FR")).toBe("us"); + }); + + it("uses the defined AQI band boundaries", () => { + expect(getAqiCategory(20, "european")).toBe("good"); + expect(getAqiCategory(21, "european")).toBe("fair"); + expect(getAqiCategory(101, "european")).toBe("extremelyPoor"); + expect(getAqiCategory(50, "us")).toBe("good"); + expect(getAqiCategory(51, "us")).toBe("moderate"); + expect(getAqiCategory(301, "us")).toBe("hazardous"); + }); + + it("finds dominant subindices and raw pollen without combining them", () => { + expect(getDominantPollutant(point, "european")).toEqual({ key: "ozone", value: 42 }); + expect(getDominantPollutant(point, "us")).toEqual({ key: "carbonMonoxide", value: 60 }); + expect(getStrongestPollen(point)).toEqual({ key: "grass", value: 20 }); + }); + + it("progressively reveals compact details", () => { + expect(getCompactAirQualityLayout(149, 200)).toMatchObject({ tiny: true }); + expect(getCompactAirQualityLayout(250, 150)).toMatchObject({ showParticulatesAndPollen: true }); + expect(getCompactAirQualityLayout(300, 170)).toMatchObject({ showSparkline: true }); + }); +}); diff --git a/packages/widgets/src/air-quality/display.ts b/packages/widgets/src/air-quality/display.ts new file mode 100644 index 000000000..08e497af5 --- /dev/null +++ b/packages/widgets/src/air-quality/display.ts @@ -0,0 +1,139 @@ +export interface AirQualityPollutants { + pm2_5: number | null; + pm10: number | null; + ozone: number | null; + nitrogenDioxide: number | null; + sulphurDioxide: number | null; + carbonMonoxide: number | null; +} + +export interface AirQualityPoint { + observedAt: string; + europeanAqi: number | null; + usAqi: number | null; + uvIndex: number | null; + pollutants: AirQualityPollutants; + europeanSubIndices: AirQualityPollutants; + usSubIndices: AirQualityPollutants; + pollen: Record<"alder" | "birch" | "grass" | "mugwort" | "olive" | "ragweed", number | null>; +} + +export interface AirQualityData { + timezone: string; + utcOffsetSeconds: number; + current: AirQualityPoint; + hourly: AirQualityPoint[]; + daily: { + date: string; + europeanAqiMax: number | null; + usAqiMax: number | null; + uvIndexMax: number | null; + pm2_5Max: number | null; + pm10Max: number | null; + }[]; +} + +export type AqiStandard = "european" | "us"; +export type AqiStandardOption = "auto" | AqiStandard; +export type AqiCategory = + | "good" + | "fair" + | "moderate" + | "unhealthySensitive" + | "unhealthy" + | "poor" + | "veryPoor" + | "veryUnhealthy" + | "extremelyPoor" + | "hazardous" + | "unknown"; +export type UvCategory = "low" | "moderate" | "high" | "veryHigh" | "extreme" | "unknown"; +export type PollutantKey = keyof AirQualityPoint["pollutants"]; +export type PollenKey = keyof AirQualityPoint["pollen"]; + +export const getAqiStandard = (option: AqiStandardOption, locale: string): AqiStandard => { + if (option !== "auto") return option; + try { + return new Intl.Locale(locale).region === "US" ? "us" : "european"; + } catch { + return "european"; + } +}; + +export const getAqiValue = (point: AirQualityPoint, standard: AqiStandard): number | null => { + if (standard === "us") return point.usAqi; + return point.europeanAqi; +}; + +export const getAqiCategory = (value: number | null, standard: AqiStandard): AqiCategory => { + if (value === null) return "unknown"; + + if (standard === "us") { + if (value <= 50) return "good"; + if (value <= 100) return "moderate"; + if (value <= 150) return "unhealthySensitive"; + if (value <= 200) return "unhealthy"; + if (value <= 300) return "veryUnhealthy"; + return "hazardous"; + } + + if (value <= 20) return "good"; + if (value <= 40) return "fair"; + if (value <= 60) return "moderate"; + if (value <= 80) return "poor"; + if (value <= 100) return "veryPoor"; + return "extremelyPoor"; +}; + +export const getAqiColor = (category: AqiCategory): string => { + if (category === "good") return "green.6"; + if (category === "fair") return "lime.7"; + if (category === "moderate") return "yellow.7"; + if (category === "unhealthySensitive" || category === "poor") return "orange.7"; + if (category === "unhealthy" || category === "veryPoor") return "red.7"; + if (category === "veryUnhealthy" || category === "extremelyPoor") return "grape.7"; + if (category === "hazardous") return "dark.6"; + return "gray.6"; +}; + +export const getUvCategory = (value: number | null): UvCategory => { + if (value === null) return "unknown"; + if (value < 3) return "low"; + if (value < 6) return "moderate"; + if (value < 8) return "high"; + if (value < 11) return "veryHigh"; + return "extreme"; +}; + +export const getDominantPollutant = ( + point: AirQualityPoint, + standard: AqiStandard, +): { key: PollutantKey; value: number } | null => { + const values = standard === "us" ? point.usSubIndices : point.europeanSubIndices; + return getHighestValue(values); +}; + +export const getStrongestPollen = (point: AirQualityPoint): { key: PollenKey; value: number } | null => + getHighestValue(point.pollen); + +const getHighestValue = ( + values: Record, +): { key: TKey; value: number } | null => { + let highest: { key: TKey; value: number } | null = null; + for (const [key, value] of Object.entries(values) as [TKey, number | null][]) { + if (value === null) continue; + if (highest === null || value > highest.value) highest = { key, value }; + } + return highest; +}; + +export const getCompactAirQualityLayout = (width: number, height: number) => ({ + tiny: width < 150 || height < 96, + showParticulatesAndPollen: width >= 240 && height >= 140, + showSparkline: width >= 300 && height >= 170, +}); + +export const getUpcomingHours = (data: AirQualityData, count: number): AirQualityData["hourly"] => { + const currentTime = Date.parse(data.current.observedAt); + return data.hourly.filter((hour) => Date.parse(hour.observedAt) >= currentTime).slice(0, count); +}; diff --git a/packages/widgets/src/air-quality/index.ts b/packages/widgets/src/air-quality/index.ts new file mode 100644 index 000000000..a9dfb28dd --- /dev/null +++ b/packages/widgets/src/air-quality/index.ts @@ -0,0 +1,43 @@ +import { IconWind } from "@tabler/icons-react"; + +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("airQuality", { + icon: IconWind, + supportsAdvancedFocus: true, + queryKey: [["widget", "airQuality", "atLocation"]], + refetchInterval: 900, + queryMatcher: ({ input }, scope) => { + const location = scope.options.location; + if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) { + return false; + } + return widgetQueryInputMatches(input, { + latitude: location.latitude, + longitude: location.longitude, + }); + }, + createOptions() { + return optionsBuilder.from((factory) => ({ + location: factory.location({ + defaultValue: { + name: "Paris", + latitude: 48.85341, + longitude: 2.3488, + }, + }), + aqiStandard: factory.select({ + defaultValue: "auto", + options: [ + { value: "auto", label: (t) => t("widget.airQuality.option.aqiStandard.options.auto") }, + { value: "european", label: (t) => t("widget.airQuality.option.aqiStandard.options.european") }, + { value: "us", label: (t) => t("widget.airQuality.option.aqiStandard.options.us") }, + ], + }), + showUv: factory.switch({ defaultValue: true }), + showPollutants: factory.switch({ defaultValue: true }), + showPollen: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index b40a3117c..6ca945d47 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -15,15 +15,18 @@ import { IconBuildingBank, IconBusStop, IconCalendar, + IconCalendarWeek, IconChartAreaLine, IconChartBar, IconClock, + IconClockPlay, IconCloud, IconDeviceCctv, IconDeviceGamepad, IconDownload, IconFileText, IconGraphFilled, + IconHourglass, IconHeadphones, IconHeartRateMonitor, IconHeartbeat, @@ -47,6 +50,7 @@ import { IconTransform, IconVideo, IconWall, + IconWind, IconZoomQuestion, } from "@tabler/icons-react"; @@ -56,6 +60,10 @@ import type { TablerIcon } from "@homarr/ui"; export const widgetCatalogIcons: Record = { clock: IconClock, weather: IconCloud, + today: IconCalendarWeek, + airQuality: IconWind, + countdown: IconHourglass, + timer: IconClockPlay, app: IconApps, assistant: IconRobot, iframe: IconBrowser, diff --git a/packages/widgets/src/clock/advanced-view.tsx b/packages/widgets/src/clock/advanced-view.tsx new file mode 100644 index 000000000..78ac239af --- /dev/null +++ b/packages/widgets/src/clock/advanced-view.tsx @@ -0,0 +1,329 @@ +import { + Alert, + Badge, + Box, + Divider, + Group, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { IconAlertTriangle, IconClock } from "@tabler/icons-react"; +import type { Dayjs } from "dayjs"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import { formatLocalizedDate, formatLocalizedTime } from "../common/locale"; +import type { WidgetComponentProps } from "../definition"; +import { clockTimeFormatShowsSeconds, clockTimeFormatUses12Hours, resolveClockTimeFormat } from "./format"; +import { TimeOfDayBar } from "./time-of-day-bar"; +import { ClockWeatherSummary } from "./weather-summary"; +import { + createWorldClockTime, + formatRelativeOffset, + formatUtcOffset, + getTimeOfDayPhase, + humanizeTimeZone, + maximumWorldClockCities, +} from "./world-clock"; +import type { TimeOfDayPhase, WorldClockTime } from "./world-clock"; + +interface AdvancedClockViewProps { + now: Date; + options: WidgetComponentProps<"clock">["options"]; + primaryTime: Dayjs; + primaryTimeZone: string; + primaryTimeZoneInvalid: boolean; +} + +export const AdvancedClockView = ({ + now, + options, + primaryTime, + primaryTimeZone, + primaryTimeZoneInvalid, +}: AdvancedClockViewProps) => { + const t = useScopedI18n("widget.clock"); + const primaryLabel = getPrimaryLabel(options, t); + const primary: WorldClockTime = { + id: "primary", + label: primaryLabel, + timeZone: primaryTimeZone, + zonedTime: primaryTime, + phase: getTimeOfDayPhase(primaryTime.hour()), + minuteOfDay: primaryTime.hour() * 60 + primaryTime.minute() + primaryTime.second() / 60, + utcOffset: primaryTime.utcOffset(), + dayDifference: 0, + }; + const cities = options.worldClockCities + .slice(0, maximumWorldClockCities) + .filter(({ timeZone }) => timeZone !== primaryTimeZone) + .map((city) => createWorldClockTime({ city, now, primaryTime })); + + return ( + + + {primaryTimeZoneInvalid && ( + } color="yellow" title={t("worldClock.unsupportedPrimaryTitle")}> + {t("worldClock.unsupportedPrimary", { timeZone: options.timezone })} + + )} + + + + + ); +}; + +interface ModeProps { + now: Date; + options: WidgetComponentProps<"clock">["options"]; + primary: WorldClockTime; + cities: WorldClockTime[]; +} + +const PrimaryClock = ({ now, options, primary }: Pick) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.clock"); + const primaryTime = primary.zonedTime; + if (!primaryTime || primary.phase === null || primary.minuteOfDay === null) return null; + + return ( + + + + + + + + + {primary.label} + + + + {options.showDate && ( + + {formatLocalizedDate(now, locale, { dateStyle: "full", timeZone: primary.timeZone })} + + )} + + + {primary.timeZone} + + + {formatUtcOffset(primaryTime.utcOffset())} + + + + {options.showWeather && ( + + + + )} + + + + + ); +}; + +const OverviewMode = ({ now, options, primary, cities }: ModeProps) => { + const t = useScopedI18n("widget.clock"); + if (cities.length === 0) return ; + + return ( + + + + {t("worldClock.cities")} + + + {t("worldClock.cityCount", { count: cities.length })} + + + + {cities.map((city) => ( + + ))} + + + ); +}; + +interface ClockEntryProps { + entry: WorldClockTime; + now: Date; + options: WidgetComponentProps<"clock">["options"]; + primaryOffset: number; +} + +const WorldClockCard = ({ entry, now, options, primaryOffset }: ClockEntryProps) => { + if (!entry.zonedTime || entry.utcOffset === null) { + return ; + } + + return ( + + + + + {entry.label} + + + {entry.timeZone} + + + + + + + ); +}; + +const ClockTime = ({ + entry, + now, + options, + primary = false, + compact = false, +}: Omit & { primary?: boolean; compact?: boolean }) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.clock"); + if (!entry.zonedTime) return null; + let formattedTime = formatLocalizedTime(now, locale, { + hour12: clockTimeFormatUses12Hours(options.customTimeFormat, options.is24HourFormat), + includeSeconds: clockTimeFormatShowsSeconds(options.customTimeFormat), + timeZone: entry.timeZone, + }); + if (primary) { + formattedTime = entry.zonedTime.format(resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat)); + } + const accessibleDate = formatLocalizedDate(now, locale, { + dateStyle: "medium", + timeZone: entry.timeZone, + }); + const fontSize = compact ? { base: 30, sm: 34 } : { base: 44, sm: 56 }; + + return ( + + {formattedTime} + + ); +}; + +const DayAndOffset = ({ entry, now, primaryOffset }: Pick) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.clock"); + if (entry.dayDifference === null || entry.utcOffset === null) return null; + const dayRelation = getDayRelation(entry.dayDifference, t); + const date = formatLocalizedDate(now, locale, { + weekday: "short", + month: "short", + day: "numeric", + timeZone: entry.timeZone, + }); + + return ( + + + {date} · {dayRelation} + + + {formatRelativeOffset(entry.utcOffset - primaryOffset)} · {formatUtcOffset(entry.utcOffset)} + + + ); +}; + +const InvalidTimeZone = ({ entry }: { entry: WorldClockTime }) => { + const t = useScopedI18n("widget.clock"); + return ( + + + + + {entry.label} + + {t("worldClock.unsupportedTimeZone", { timeZone: entry.timeZone })} + + + + + ); +}; + +const EmptyCities = () => { + const t = useScopedI18n("widget.clock"); + return ( + + + {t("worldClock.noSecondaryCities")} + + + ); +}; + +type ScopedClockTranslator = ReturnType>; + +const getDayRelation = (difference: number, t: ScopedClockTranslator) => { + if (difference < 0) return t("worldClock.yesterday"); + if (difference > 0) return t("worldClock.tomorrow"); + return t("worldClock.today"); +}; + +const getPrimaryLabel = (options: ModeProps["options"], t: ScopedClockTranslator) => { + if (options.customTitleToggle) return options.customTitle || t("worldClock.primary"); + if (options.useCustomTimezone) return humanizeTimeZone(options.timezone); + return t("worldClock.local"); +}; + +const getPhaseColor = (phase: TimeOfDayPhase) => { + if (phase === "night") return "indigo"; + if (phase === "dawn") return "orange"; + if (phase === "day") return "cyan"; + return "grape"; +}; diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 0d17356fe..45180cff9 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -1,51 +1,62 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Group, Loader, Stack, Text, Title } from "@mantine/core"; +import { Box, Center, Stack, Text, Title } from "@mantine/core"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; -import timezones from "dayjs/plugin/timezone"; -import utc from "dayjs/plugin/utc"; -import { clientApi } from "@homarr/api/client"; -import { metricToImperial } from "@homarr/common"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; - -import { isInitialWidgetQueryPending } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; -import { AnimatedWeatherIcon } from "../weather/animated-icon"; -import { WeatherDescription } from "../weather/icon"; +import { AdvancedClockView } from "./advanced-view"; +import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; +import { useCurrentTime } from "./use-current-time"; +import { ClockWeatherSummary } from "./weather-summary"; +import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock"; dayjs.extend(advancedFormat); -dayjs.extend(utc); -dayjs.extend(timezones); export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) { const isAdvanced = displayMode === "advanced"; - const secondsFormat = options.showSeconds || isAdvanced ? ":ss" : ""; - const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`; - const dateFormat = options.dateFormat; - const customTimeFormat = options.customTimeFormat; - const customDateFormat = options.customDateFormat; - const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone; - const time = useCurrentTime({ showSeconds: options.showSeconds || isAdvanced }); - const zonedTime = time === null ? null : dayjs(time).tz(timezone); + const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); + const time = useCurrentTime({ showSeconds }); + const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone(); + const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone); + const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone; + const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone); + if (isAdvanced) { + if (time && zonedTime) { + return ( + + ); + } + return
--:--
; + } + + const resolvedTimeFormat = resolveClockTimeFormat(options.customTimeFormat, options.is24HourFormat); const minimumAxis = Math.min(width, height * 1.5); - const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md"; + let sizing: "xs" | "sm" | "md" = "md"; + if (minimumAxis < 128) sizing = "xs"; + else if (minimumAxis < 196) sizing = "sm"; const showWeatherCorner = options.showWeather && sizing !== "xs"; return ( {showWeatherCorner && ( - + + + )} {options.customTitleToggle && ( @@ -53,19 +64,14 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid {options.customTitle} )} - + <Title className="clock-time-text" fw={700} order={getTitleOrder(sizing)} lh="1"> <time dateTime={zonedTime?.toISOString()}> - {zonedTime === null ? "--:--" : zonedTime.format(options.customTimeFormat ? customTimeFormat : timeFormat)} + {zonedTime === null ? "--:--" : zonedTime.format(resolvedTimeFormat)} </time> - {(isAdvanced || options.showDate) && ( + {options.showDate && ( - {zonedTime?.format(options.customDateFormat ? customDateFormat : dateFormat)} - - )} - {isAdvanced && ( - - {zonedTime === null ? null : `${timezone} · UTC${zonedTime.format("Z")}`} + {zonedTime?.format(options.dateFormat)} )} @@ -73,117 +79,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid ); } -interface ClockWeatherCornerProps { - latitude: number; - longitude: number; - locationName: string; - isFahrenheit: boolean; - showDetails: boolean; -} - -const ClockWeatherCorner = ({ - latitude, - longitude, - locationName, - isFahrenheit, - showDetails, -}: ClockWeatherCornerProps) => { - const t = useI18n(); - const tCommon = useScopedI18n("common"); - const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude }); - const weather = weatherQuery.data; - - if (!weather) { - return ( - - {isInitialWidgetQueryPending(weatherQuery) ? ( - - ) : ( - - )} - - ); - } - - const temp = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature; - const unit = isFahrenheit ? "°F" : "°C"; - const today = weather.daily[0]; - const formatTemperature = (value: number | undefined) => { - if (value === undefined) return "—"; - const preferred = isFahrenheit ? value * (9 / 5) + 32 : value; - return `${Math.round(preferred)}${unit}`; - }; - - return ( - - - - - {Math.round(temp)} - {unit} - - {showDetails && } - - {showDetails && ( - - - {locationName} - - - {formatTemperature(today?.maxTemp)} / {formatTemperature(today?.minTemp)} · {today?.humidity ?? "—"}% - - - {Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "} - {isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")} - - - {t("widget.weather.dailyForecast.sunrise")}: {formatClockWeatherTime(today?.sunrise)} ·{" "} - {t("widget.weather.dailyForecast.sunset")}: {formatClockWeatherTime(today?.sunset)} - - - )} - - - ); -}; - -const formatClockWeatherTime = (value: string | undefined) => value?.split("T")[1] ?? "—"; - -interface UseCurrentTimeProps { - showSeconds: boolean; -} - -const useCurrentTime = ({ showSeconds }: UseCurrentTimeProps) => { - const [time, setTime] = useState(null); - const timeoutRef = useRef(null); - const intervalRef = useRef(null); - const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]); - - useEffect(() => { - setTime(new Date()); - timeoutRef.current = setTimeout( - () => { - setTime(new Date()); - - intervalRef.current = setInterval(() => { - setTime(new Date()); - }, intervalMultiplier * 1000); - }, - intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()), - ); - - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [intervalMultiplier, showSeconds]); - - return time; +const getTitleOrder = (sizing: "xs" | "sm" | "md") => { + if (sizing === "md") return 2; + if (sizing === "sm") return 4; + return 6; }; diff --git a/packages/widgets/src/clock/format.ts b/packages/widgets/src/clock/format.ts new file mode 100644 index 000000000..934bc818d --- /dev/null +++ b/packages/widgets/src/clock/format.ts @@ -0,0 +1,19 @@ +export const automaticTimeFormat = "automatic"; + +export const resolveClockTimeFormat = (format: string, legacyIs24HourFormat: boolean) => { + if (format && format !== automaticTimeFormat) return format; + if (legacyIs24HourFormat) return "HH:mm"; + return "hh:mm A"; +}; + +export const clockTimeFormatUses12Hours = (format: string, legacyIs24HourFormat: boolean) => { + if (!format || format === automaticTimeFormat) return !legacyIs24HourFormat; + const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, ""); + return /h{1,2}/.test(formatWithoutLiterals); +}; + +export const clockTimeFormatShowsSeconds = (format: string) => { + if (!format || format === automaticTimeFormat) return false; + const formatWithoutLiterals = format.replace(/\[[^\]]*\]/g, ""); + return /s{1,2}/.test(formatWithoutLiterals); +}; diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index e7657b918..8f3fdd06f 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -3,9 +3,18 @@ import dayjs from "dayjs"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; +import { + defaultWorldClockCities, + getTimeZoneOptions, + maximumWorldClockCities, + worldClockCityPresets, +} from "./world-clock"; + +const timeZoneOptions = getTimeZoneOptions(); export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, + supportsAdvancedFocus: true, queryKey: [["widget", "weather", "atLocation"]], queryMatcher: ({ input }, scope) => { const location = scope.options.weatherLocation; @@ -37,12 +46,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", { defaultValue: true, withDescription: true, }), - showSeconds: factory.switch({ - defaultValue: false, - }), useCustomTimezone: factory.switch({ defaultValue: false }), timezone: factory.select({ - options: Intl.supportedValuesOf("timeZone").map((value) => value), + options: timeZoneOptions, defaultValue: "Europe/London", searchable: true, withDescription: true, @@ -52,24 +58,29 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", { }), dateFormat: factory.select({ options: [ - { value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") }, - { value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") }, - { value: "MMM D", label: dayjs().format("MMM D") }, - { value: "D MMM", label: dayjs().format("D MMM") }, - { value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") }, - { value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") }, - { value: "DD/MM", label: dayjs().format("DD/MM") }, - { value: "MM/DD", label: dayjs().format("MM/DD") }, + { value: "YYYY-MM-DD", label: `${dayjs().format("YYYY-MM-DD")} · YYYY-MM-DD` }, + { value: "DD/MM/YYYY", label: `${dayjs().format("DD/MM/YYYY")} · DD/MM/YYYY` }, + { value: "MM/DD/YYYY", label: `${dayjs().format("MM/DD/YYYY")} · MM/DD/YYYY` }, + { value: "dddd, MMMM D", label: `${dayjs().format("dddd, MMMM D")} · dddd, MMMM D` }, + { value: "dddd, D MMMM", label: `${dayjs().format("dddd, D MMMM")} · dddd, D MMMM` }, + { value: "MMM D, YYYY", label: `${dayjs().format("MMM D, YYYY")} · MMM D, YYYY` }, + { value: "D MMM YYYY", label: `${dayjs().format("D MMM YYYY")} · D MMM YYYY` }, + { value: "MMM D", label: `${dayjs().format("MMM D")} · MMM D` }, + { value: "D MMM", label: `${dayjs().format("D MMM")} · D MMM` }, + { value: "DD/MM", label: `${dayjs().format("DD/MM")} · DD/MM` }, + { value: "MM/DD", label: `${dayjs().format("MM/DD")} · MM/DD` }, ], defaultValue: "dddd, MMMM D", withDescription: true, }), - customTimeFormat: factory.text({ - defaultValue: "", - withDescription: true, - }), - customDateFormat: factory.text({ - defaultValue: "", + customTimeFormat: factory.select({ + options: [ + { value: "HH:mm", label: `${dayjs().format("HH:mm")} · HH:mm` }, + { value: "HH:mm:ss", label: `${dayjs().format("HH:mm:ss")} · HH:mm:ss` }, + { value: "h:mm A", label: `${dayjs().format("h:mm A")} · h:mm A` }, + { value: "h:mm:ss A", label: `${dayjs().format("h:mm:ss A")} · h:mm:ss A` }, + ], + defaultValue: "HH:mm", withDescription: true, }), showWeather: factory.switch({ @@ -86,8 +97,22 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", { isWeatherFormatFahrenheit: factory.switch({ defaultValue: false, }), + animateWeatherIcon: factory.switch({ + defaultValue: false, + withDescription: true, + }), + worldClockCities: factory.timezoneList({ + defaultValue: defaultWorldClockCities.map((city) => ({ ...city })), + maxValues: maximumWorldClockCities, + presets: worldClockCityPresets, + timeZoneOptions, + withDescription: true, + }), }), { + is24HourFormat: { + shouldHide: () => true, + }, customTitle: { shouldHide: (options) => !options.customTitleToggle, }, @@ -103,6 +128,9 @@ export const { definition, componentLoader } = createWidgetDefinition("clock", { isWeatherFormatFahrenheit: { shouldHide: (options) => !options.showWeather, }, + animateWeatherIcon: { + shouldHide: (options) => !options.showWeather, + }, }, ); }, diff --git a/packages/widgets/src/clock/time-of-day-bar.tsx b/packages/widgets/src/clock/time-of-day-bar.tsx new file mode 100644 index 000000000..9f4ba5dce --- /dev/null +++ b/packages/widgets/src/clock/time-of-day-bar.tsx @@ -0,0 +1,106 @@ +import { Badge, Box, Group, Progress, Stack, Text } from "@mantine/core"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import type { TimeOfDayPhase } from "./world-clock"; + +interface TimeOfDayBarProps { + minuteOfDay: number; + phase: TimeOfDayPhase; + showLegend?: boolean; + showTicks?: boolean; + label: string; +} + +const phaseSections = [ + { key: "nightStart", value: 20.833, color: "indigo.8" }, + { key: "dawn", value: 12.5, color: "orange.6" }, + { key: "day", value: 41.667, color: "cyan.6" }, + { key: "dusk", value: 12.5, color: "grape.6" }, + { key: "nightEnd", value: 12.5, color: "indigo.8" }, +] as const; + +const phaseLegend = [ + { key: "night", range: "00–05 · 21–24", color: "indigo.8" }, + { key: "dawn", range: "05–08", color: "orange.6" }, + { key: "day", range: "08–18", color: "cyan.6" }, + { key: "dusk", range: "18–21", color: "grape.6" }, +] as const; + +export const TimeOfDayBar = ({ + minuteOfDay, + phase, + showLegend = false, + showTicks = false, + label, +}: TimeOfDayBarProps) => { + const t = useScopedI18n("widget.clock"); + const progress = Math.max(0, Math.min(100, (minuteOfDay / 1440) * 100)); + const remainingMinutes = Math.max(0, 1440 - Math.floor(minuteOfDay)); + const remainingHours = Math.floor(remainingMinutes / 60); + const remainder = remainingMinutes % 60; + + return ( + + + + {t(`worldClock.phase.${phase}`)} + + + {t("worldClock.dayProgress", { + percent: Math.round(progress), + hours: remainingHours, + minutes: remainder.toString().padStart(2, "0"), + })} + + + + + {phaseSections.map((section) => ( + + ))} + + + + {showTicks && ( + + {["00", "06", "12", "18", "24"].map((tick) => ( + + {tick} + + ))} + + )} + {showLegend && ( + + + {t("worldClock.phaseLegend")} + + {phaseLegend.map((item) => ( + + + + {t(`worldClock.phase.${item.key}`)} · {item.range} + + + ))} + + )} + + ); +}; + +const getPhaseColor = (phase: TimeOfDayPhase) => { + if (phase === "night") return "indigo"; + if (phase === "dawn") return "orange"; + if (phase === "day") return "cyan"; + return "grape"; +}; diff --git a/packages/widgets/src/clock/use-current-time.ts b/packages/widgets/src/clock/use-current-time.ts new file mode 100644 index 000000000..6d7b30a6f --- /dev/null +++ b/packages/widgets/src/clock/use-current-time.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import dayjs from "dayjs"; + +export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => { + const [time, setTime] = useState(null); + const timeoutRef = useRef>(null); + const intervalRef = useRef>(null); + const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]); + + useEffect(() => { + setTime(new Date()); + timeoutRef.current = setTimeout( + () => { + setTime(new Date()); + intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000); + }, + intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()), + ); + + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [intervalMultiplier, showSeconds]); + + return time; +}; diff --git a/packages/widgets/src/clock/weather-summary.tsx b/packages/widgets/src/clock/weather-summary.tsx new file mode 100644 index 000000000..bb187a7bd --- /dev/null +++ b/packages/widgets/src/clock/weather-summary.tsx @@ -0,0 +1,78 @@ +import { Box, Group, Loader, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; + +import { isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; +import { AnimatedWeatherIcon } from "../weather/animated-icon"; +import { WeatherDescription } from "../weather/icon"; + +interface ClockWeatherSummaryProps { + latitude: number; + longitude: number; + locationName: string; + isFahrenheit: boolean; + animateIcon: boolean; + detailed: boolean; +} + +export const ClockWeatherSummary = ({ + latitude, + longitude, + locationName, + isFahrenheit, + animateIcon, + detailed, +}: ClockWeatherSummaryProps) => { + const t = useI18n(); + const tCommon = useScopedI18n("common"); + const weatherQuery = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude }); + const weather = weatherQuery.data; + + if (!weather) { + return isInitialWidgetQueryPending(weatherQuery) ? ( + + ) : ( + + ); + } + + const temperature = isFahrenheit ? weather.current.temperature * (9 / 5) + 32 : weather.current.temperature; + const unit = isFahrenheit ? "°F" : "°C"; + const today = weather.daily[0]; + const formatTemperature = (value: number | null | undefined) => { + if (value === undefined || value === null) return "—"; + const preferred = isFahrenheit ? value * (9 / 5) + 32 : value; + return `${Math.round(preferred)}${unit}`; + }; + + return ( + + + + + {Math.round(temperature)} + {unit} + + {detailed && } + + {detailed && ( + + + {locationName} + + + {formatTemperature(today?.maxTemperature)} / {formatTemperature(today?.minTemperature)} + + + )} + + + ); +}; diff --git a/packages/widgets/src/clock/world-clock.spec.ts b/packages/widgets/src/clock/world-clock.spec.ts new file mode 100644 index 000000000..47b134a43 --- /dev/null +++ b/packages/widgets/src/clock/world-clock.spec.ts @@ -0,0 +1,63 @@ +import dayjs from "dayjs"; +import { describe, expect, test } from "vitest"; + +import { + createWorldClockTime, + formatRelativeOffset, + formatUtcOffset, + getTimeOfDayPhase, + getTimeZoneOptions, +} from "./world-clock"; + +describe("world clock helpers", () => { + test("adds UTC to the available timezone options", () => { + expect(getTimeZoneOptions()).toContainEqual({ value: "UTC", label: "UTC (UTC)" }); + }); + + test("classifies the fixed time-of-day phases at their boundaries", () => { + expect([0, 4, 5, 7, 8, 17, 18, 20, 21, 23].map(getTimeOfDayPhase)).toEqual([ + "night", + "night", + "dawn", + "dawn", + "day", + "day", + "dusk", + "dusk", + "night", + "night", + ]); + }); + + test("formats whole-hour and fractional offsets", () => { + expect(formatUtcOffset(345)).toBe("UTC+05:45"); + expect(formatRelativeOffset(-210)).toBe("−3h 30m"); + expect(formatRelativeOffset(0)).toBe("±0h"); + }); + + test("compares civil dates across the date line from one instant", () => { + const now = new Date("2026-01-01T02:30:00.000Z"); + const primaryTime = dayjs(now).tz("America/New_York"); + const tokyo = createWorldClockTime({ + city: { id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" }, + now, + primaryTime, + }); + + expect(primaryTime.format("YYYY-MM-DD")).toBe("2025-12-31"); + expect(tokyo.zonedTime?.format("YYYY-MM-DD")).toBe("2026-01-01"); + expect(tokyo.dayDifference).toBe(1); + }); + + test("isolates unsupported timezones", () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + const result = createWorldClockTime({ + city: { id: "invalid", label: "Invalid", timeZone: "Invalid/Timezone" }, + now, + primaryTime: dayjs(now).tz("UTC"), + }); + + expect(result.zonedTime).toBeNull(); + expect(result.utcOffset).toBeNull(); + }); +}); diff --git a/packages/widgets/src/clock/world-clock.ts b/packages/widgets/src/clock/world-clock.ts new file mode 100644 index 000000000..e951fa916 --- /dev/null +++ b/packages/widgets/src/clock/world-clock.ts @@ -0,0 +1,127 @@ +import dayjs from "dayjs"; +import type { Dayjs } from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; + +import type { OptionTimezone } from "../options"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export const maximumWorldClockCities = 6; + +export const worldClockCityPresets = [ + { id: "new-york", label: "New York", timeZone: "America/New_York" }, + { id: "london", label: "London", timeZone: "Europe/London" }, + { id: "paris", label: "Paris", timeZone: "Europe/Paris" }, + { id: "tokyo", label: "Tokyo", timeZone: "Asia/Tokyo" }, + { id: "sydney", label: "Sydney", timeZone: "Australia/Sydney" }, + { id: "utc", label: "UTC", timeZone: "UTC" }, +] as const satisfies readonly OptionTimezone[]; + +export const defaultWorldClockCities = worldClockCityPresets.filter(({ id }) => + ["new-york", "paris", "tokyo"].includes(id), +); + +export const getTimeZoneOptions = () => { + const timeZones = Intl.supportedValuesOf("timeZone"); + if (!timeZones.includes("UTC")) timeZones.push("UTC"); + + return timeZones.map((timeZone) => ({ + value: timeZone, + label: `${humanizeTimeZone(timeZone)} (${timeZone})`, + })); +}; + +export const humanizeTimeZone = (timeZone: string) => { + if (timeZone === "UTC") return "UTC"; + const segments = timeZone.split("/"); + return (segments.at(-1) ?? timeZone).replaceAll("_", " "); +}; + +export const isTimeZoneSupported = (timeZone: string) => { + try { + new Intl.DateTimeFormat("en", { timeZone }).format(0); + return true; + } catch { + return false; + } +}; + +export type TimeOfDayPhase = "night" | "dawn" | "day" | "dusk"; + +export interface WorldClockTime { + id: string; + label: string; + timeZone: string; + zonedTime: Dayjs | null; + phase: TimeOfDayPhase | null; + minuteOfDay: number | null; + utcOffset: number | null; + dayDifference: number | null; +} + +export const getResolvedLocalTimeZone = () => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC"; +}; + +export const createWorldClockTime = ({ + city, + now, + primaryTime, +}: { + city: OptionTimezone; + now: Date; + primaryTime: Dayjs; +}): WorldClockTime => { + if (!isTimeZoneSupported(city.timeZone)) { + return { + ...city, + zonedTime: null, + phase: null, + minuteOfDay: null, + utcOffset: null, + dayDifference: null, + }; + } + + const zonedTime = dayjs(now).tz(city.timeZone); + return { + ...city, + zonedTime, + phase: getTimeOfDayPhase(zonedTime.hour()), + minuteOfDay: zonedTime.hour() * 60 + zonedTime.minute() + zonedTime.second() / 60, + utcOffset: zonedTime.utcOffset(), + dayDifference: getCivilDayNumber(zonedTime) - getCivilDayNumber(primaryTime), + }; +}; + +export const getTimeOfDayPhase = (hour: number): TimeOfDayPhase => { + if (hour < 5 || hour >= 21) return "night"; + if (hour < 8) return "dawn"; + if (hour < 18) return "day"; + return "dusk"; +}; + +export const formatUtcOffset = (minutes: number) => { + if (minutes === 0) return "UTC±00:00"; + const sign = minutes < 0 ? "−" : "+"; + const absoluteMinutes = Math.abs(minutes); + const hours = Math.floor(absoluteMinutes / 60) + .toString() + .padStart(2, "0"); + const remainingMinutes = (absoluteMinutes % 60).toString().padStart(2, "0"); + return `UTC${sign}${hours}:${remainingMinutes}`; +}; + +export const formatRelativeOffset = (minutes: number) => { + if (minutes === 0) return "±0h"; + const sign = minutes < 0 ? "−" : "+"; + const absoluteMinutes = Math.abs(minutes); + const hours = Math.floor(absoluteMinutes / 60); + const remainingMinutes = absoluteMinutes % 60; + return remainingMinutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${remainingMinutes}m`; +}; + +const getCivilDayNumber = (value: Dayjs) => Date.UTC(value.year(), value.month(), value.date()) / 86_400_000; diff --git a/packages/widgets/src/common/browser-alert.spec.ts b/packages/widgets/src/common/browser-alert.spec.ts new file mode 100644 index 000000000..9538e001c --- /dev/null +++ b/packages/widgets/src/common/browser-alert.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { addRememberedBrowserAlert, requestBrowserNotificationPermission } from "./browser-alert"; + +const originalNotification = window.Notification; + +afterEach(() => { + if (originalNotification === undefined) { + Reflect.deleteProperty(window, "Notification"); + return; + } + Object.defineProperty(window, "Notification", { configurable: true, value: originalNotification }); +}); + +describe("browser alert occurrence history", () => { + it("moves a repeated occurrence to the end without duplicating it", () => { + expect(addRememberedBrowserAlert(["first", "second"], "first")).toEqual(["second", "first"]); + }); + + it("bounds remembered occurrences", () => { + const values = Array.from({ length: 110 }, (_, index) => String(index)); + const result = addRememberedBrowserAlert(values, "next"); + expect(result).toHaveLength(100); + expect(result.at(-1)).toBe("next"); + }); + + it("keeps the current permission when the browser rejects a permission request", async () => { + Object.defineProperty(window, "Notification", { + configurable: true, + value: { + permission: "default", + requestPermission: vi.fn().mockRejectedValue(new Error("blocked")), + }, + }); + + await expect(requestBrowserNotificationPermission()).resolves.toBe("default"); + }); +}); diff --git a/packages/widgets/src/common/browser-alert.ts b/packages/widgets/src/common/browser-alert.ts new file mode 100644 index 000000000..e4bb54389 --- /dev/null +++ b/packages/widgets/src/common/browser-alert.ts @@ -0,0 +1,87 @@ +export type BrowserNotificationPermission = NotificationPermission | "unsupported"; + +export interface BrowserNotificationOptions { + title: string; + body?: string; + icon?: string; + tag?: string; +} + +const maxRememberedOccurrences = 100; + +export const getBrowserNotificationPermission = (): BrowserNotificationPermission => { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + return window.Notification.permission; +}; + +export const requestBrowserNotificationPermission = async (): Promise => { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + try { + return await window.Notification.requestPermission(); + } catch { + return getBrowserNotificationPermission(); + } +}; + +export const showBrowserNotification = ({ title, body, icon, tag }: BrowserNotificationOptions): boolean => { + if (typeof window === "undefined" || !("Notification" in window) || window.Notification.permission !== "granted") { + return false; + } + + try { + const notification = new window.Notification(title, { body, icon, tag }); + void notification; + return true; + } catch { + return false; + } +}; + +export const playBrowserAlertSound = (): boolean => { + if (typeof window === "undefined") return false; + const AudioContextConstructor = + window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextConstructor) return false; + + try { + const context = new AudioContextConstructor(); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const start = context.currentTime; + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(880, start); + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.3); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(start); + oscillator.stop(start + 0.32); + oscillator.addEventListener("ended", () => void context.close(), { once: true }); + return true; + } catch { + return false; + } +}; + +export const addRememberedBrowserAlert = (occurrences: string[], occurrence: string): string[] => { + const withoutDuplicate = occurrences.filter((value) => value !== occurrence); + return [...withoutDuplicate, occurrence].slice(-maxRememberedOccurrences); +}; + +export const claimBrowserAlertOccurrence = (storageKey: string, occurrence: string): boolean => { + if (typeof window === "undefined") return false; + + try { + const serialized = window.localStorage.getItem(storageKey); + const parsed: unknown = serialized === null ? [] : JSON.parse(serialized); + const occurrences = Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === "string") + : []; + if (occurrences.includes(occurrence)) return false; + window.localStorage.setItem(storageKey, JSON.stringify(addRememberedBrowserAlert(occurrences, occurrence))); + return true; + } catch { + return false; + } +}; diff --git a/packages/widgets/src/common/use-local-widget-state.spec.ts b/packages/widgets/src/common/use-local-widget-state.spec.ts new file mode 100644 index 000000000..c3ddad228 --- /dev/null +++ b/packages/widgets/src/common/use-local-widget-state.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { parseLocalWidgetState, serializeLocalWidgetState } from "./use-local-widget-state"; + +const isCounterState = (value: unknown): value is { count: number } => + typeof value === "object" && value !== null && "count" in value && typeof value.count === "number"; + +describe("local widget state serialization", () => { + it("round trips validated state", () => { + const serialized = serializeLocalWidgetState(1, { count: 3 }); + expect(parseLocalWidgetState(serialized, 1, isCounterState)).toEqual({ count: 3 }); + }); + + it("rejects malformed and invalid state", () => { + expect(parseLocalWidgetState("not-json", 1, isCounterState)).toBeUndefined(); + expect(parseLocalWidgetState('{"version":1,"value":{"count":"3"}}', 1, isCounterState)).toBeUndefined(); + }); + + it("uses the supplied migration for old versions", () => { + const serialized = serializeLocalWidgetState(1, { oldCount: 4 }); + expect( + parseLocalWidgetState(serialized, 2, isCounterState, (value) => { + if (typeof value !== "object" || value === null || !("oldCount" in value)) return undefined; + return { count: Number(value.oldCount) }; + }), + ).toEqual({ count: 4 }); + }); +}); diff --git a/packages/widgets/src/common/use-local-widget-state.ts b/packages/widgets/src/common/use-local-widget-state.ts new file mode 100644 index 000000000..c32d30278 --- /dev/null +++ b/packages/widgets/src/common/use-local-widget-state.ts @@ -0,0 +1,145 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface StoredWidgetState { + version: number; + value: unknown; +} + +interface UseLocalWidgetStateOptions { + key: string | undefined; + version: number; + defaultValue: T | (() => T); + validate?: (value: unknown) => value is T; + migrate?: (value: unknown, storedVersion: number) => T | undefined; +} + +const resolveDefaultValue = (value: T | (() => T)): T => + typeof value === "function" ? (value as () => T)() : value; + +export const serializeLocalWidgetState = (version: number, value: T): string => JSON.stringify({ version, value }); + +export const parseLocalWidgetState = ( + serialized: string, + version: number, + validate?: (value: unknown) => value is T, + migrate?: (value: unknown, storedVersion: number) => T | undefined, +): T | undefined => { + try { + const parsed: unknown = JSON.parse(serialized); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) { + return undefined; + } + + const stored = parsed as StoredWidgetState; + if (typeof stored.version !== "number") return undefined; + if (stored.version !== version) { + const migrated = migrate?.(stored.value, stored.version); + if (migrated === undefined || (validate && !validate(migrated))) return undefined; + return migrated; + } + if (validate && !validate(stored.value)) return undefined; + return stored.value as T; + } catch { + return undefined; + } +}; + +export const useLocalWidgetState = ({ + key, + version, + defaultValue, + validate, + migrate, +}: UseLocalWidgetStateOptions): [T, Dispatch>, () => void] => { + const defaultValueRef = useRef(defaultValue); + const validateRef = useRef(validate); + const migrateRef = useRef(migrate); + validateRef.current = validate; + migrateRef.current = migrate; + + const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []); + const [value, setValueState] = useState(createDefaultValue); + const valueRef = useRef(value); + + const setMemoryValue = useCallback((nextValue: T) => { + valueRef.current = nextValue; + setValueState(nextValue); + }, []); + + const removeStoredValue = useCallback(() => { + if (!key) return; + try { + window.localStorage.removeItem(key); + } catch { + // Storage can be unavailable in private or restricted browser contexts. + } + }, [key]); + + useEffect(() => { + if (!key) { + setMemoryValue(createDefaultValue()); + return; + } + + try { + const serialized = window.localStorage.getItem(key); + if (serialized === null) { + setMemoryValue(createDefaultValue()); + return; + } + const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current); + if (parsed === undefined) { + window.localStorage.removeItem(key); + setMemoryValue(createDefaultValue()); + return; + } + setMemoryValue(parsed); + } catch { + setMemoryValue(createDefaultValue()); + } + }, [createDefaultValue, key, setMemoryValue, version]); + + useEffect(() => { + if (!key) return; + + const handleStorage = (event: StorageEvent) => { + if (event.storageArea !== window.localStorage || event.key !== key) return; + if (event.newValue === null) { + setMemoryValue(createDefaultValue()); + return; + } + + const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current); + if (parsed !== undefined) setMemoryValue(parsed); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [createDefaultValue, key, setMemoryValue, version]); + + const setValue: Dispatch> = useCallback( + (nextValue) => { + const resolved = + typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue; + if (Object.is(resolved, valueRef.current)) return; + setMemoryValue(resolved); + if (key) { + try { + window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved)); + } catch { + // Keep the in-memory state usable when persistence is unavailable. + } + } + }, + [key, setMemoryValue, version], + ); + + const resetValue = useCallback(() => { + removeStoredValue(); + setMemoryValue(createDefaultValue()); + }, [createDefaultValue, removeStoredValue, setMemoryValue]); + + return [value, setValue, resetValue]; +}; diff --git a/packages/widgets/src/common/use-widget-now.spec.ts b/packages/widgets/src/common/use-widget-now.spec.ts new file mode 100644 index 000000000..8c7c81a56 --- /dev/null +++ b/packages/widgets/src/common/use-widget-now.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { getNextWidgetTickDelay } from "./use-widget-now"; + +describe("getNextWidgetTickDelay", () => { + it("aligns second and minute ticks", () => { + const now = new Date(2026, 7, 20, 12, 34, 45, 250); + expect(getNextWidgetTickDelay(now, "second")).toBe(750); + expect(getNextWidgetTickDelay(now, "minute")).toBe(14_750); + }); + + it("aligns day ticks to the next local midnight", () => { + const now = new Date(2026, 7, 20, 23, 59, 59, 500); + expect(getNextWidgetTickDelay(now, "day")).toBe(500); + }); +}); diff --git a/packages/widgets/src/common/use-widget-now.ts b/packages/widgets/src/common/use-widget-now.ts new file mode 100644 index 000000000..40f7c88cd --- /dev/null +++ b/packages/widgets/src/common/use-widget-now.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export type WidgetClockPrecision = "second" | "minute" | "day"; + +const minimumDelayMs = 10; + +export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecision): number => { + let next: number; + if (precision === "second") { + next = now.getTime() - now.getMilliseconds() + 1_000; + } else if (precision === "minute") { + next = now.getTime() - now.getSeconds() * 1_000 - now.getMilliseconds() + 60_000; + } else { + next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime(); + } + + return Math.max(minimumDelayMs, next - now.getTime()); +}; + +export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => { + const [now, setNow] = useState(null); + const timeoutRef = useRef>(undefined); + + const cancelTick = useCallback(() => { + if (timeoutRef.current === undefined) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = undefined; + }, []); + + const scheduleTick = useCallback(() => { + cancelTick(); + const current = new Date(); + setNow(current); + timeoutRef.current = setTimeout(scheduleTick, getNextWidgetTickDelay(current, precision)); + }, [cancelTick, precision]); + + useEffect(() => { + scheduleTick(); + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") scheduleTick(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + cancelTick(); + }; + }, [cancelTick, scheduleTick]); + + return now; +}; diff --git a/packages/widgets/src/countdown/component.module.css b/packages/widgets/src/countdown/component.module.css new file mode 100644 index 000000000..21b9d50f1 --- /dev/null +++ b/packages/widgets/src/countdown/component.module.css @@ -0,0 +1,42 @@ +.root { + container-type: size; + overflow: hidden; +} + +.duration { + font-variant-numeric: tabular-nums; + letter-spacing: -0.04em; +} + +.compactDuration { + font-size: var(--mantine-font-size-xl); + white-space: nowrap; +} + +.compactProgress { + width: 100%; + max-width: 14rem; +} + +.eventCard { + min-width: 0; +} + +@container (max-width: 170px) { + .compactStatus, + .compactTarget { + display: none; + } +} + +@container (max-width: 140px) { + .compactDuration { + font-size: clamp(var(--mantine-font-size-sm), 15cqw, var(--mantine-font-size-md)); + } +} + +@container (max-height: 105px) { + .compactTarget { + display: none; + } +} diff --git a/packages/widgets/src/countdown/component.tsx b/packages/widgets/src/countdown/component.tsx new file mode 100644 index 000000000..9f97185c5 --- /dev/null +++ b/packages/widgets/src/countdown/component.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + Badge, + Group, + Paper, + Progress, + ScrollArea, + SimpleGrid, + Stack, + Switch, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { IconBell, IconBellOff, IconHourglass } from "@tabler/icons-react"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import { + claimBrowserAlertOccurrence, + getBrowserNotificationPermission, + requestBrowserNotificationPermission, + showBrowserNotification, +} from "../common/browser-alert"; +import { useLocalWidgetState } from "../common/use-local-widget-state"; +import { useWidgetNow } from "../common/use-widget-now"; +import type { WidgetComponentProps } from "../definition"; +import { formatCountdownDuration } from "./display"; +import styles from "./component.module.css"; +import { getCountdownEventState, getCountdownProgress, groupCountdownStates } from "./recurrence"; +import type { CountdownEventState } from "./recurrence"; + +interface AlertPreferences { + notifications: boolean; +} + +const defaultAlertPreferences: AlertPreferences = { notifications: false }; + +const isAlertPreferences = (value: unknown): value is AlertPreferences => { + if (typeof value !== "object" || value === null) return false; + return "notifications" in value && typeof value.notifications === "boolean"; +}; + +const migrateAlertPreferences = (value: unknown, storedVersion: number): AlertPreferences | undefined => { + if (storedVersion !== 1 || !isAlertPreferences(value)) return undefined; + return { notifications: value.notifications }; +}; + +export default function CountdownWidget({ + boardId, + displayMode, + isEditMode, + itemId, + options, + width, +}: WidgetComponentProps<"countdown">) { + const now = useWidgetNow(options.showSeconds ? "second" : "minute"); + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.countdown"); + const storagePrefix = boardId && itemId ? `homarr:countdown:${boardId}:${itemId}` : undefined; + const [preferences, setPreferences] = useLocalWidgetState({ + key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined, + version: 2, + defaultValue: defaultAlertPreferences, + validate: isAlertPreferences, + migrate: migrateAlertPreferences, + }); + const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission()); + const previousNowRef = useRef(null); + const states = useMemo(() => { + if (!now) return []; + return options.events.map((event) => getCountdownEventState(event, now)); + }, [now, options.events]); + const grouped = useMemo(() => groupCountdownStates(states), [states]); + + useEffect(() => { + if (!now || !storagePrefix) return; + const previousNow = previousNowRef.current; + previousNowRef.current = now; + if (!previousNow || !preferences.notifications) return; + + states.forEach((state) => { + const occurrence = state.previousOccurrence; + if (!occurrence || occurrence <= previousNow || occurrence > now) return; + const claimKey = `${storagePrefix}:alerted-occurrences`; + const occurrenceKey = `${state.event.id}:${occurrence.toISOString()}`; + if (!claimBrowserAlertOccurrence(claimKey, occurrenceKey)) return; + showBrowserNotification({ + title: state.event.label, + body: t("alert.reached"), + tag: occurrenceKey, + }); + }); + }, [now, preferences.notifications, states, storagePrefix, t]); + + if (!now) { + return ( + + — + + ); + } + + const hero = states[0]; + if (!hero) { + return ( + + + + + {t("empty.title")} + + {t("empty.description")} + + + ); + } + + let heroStatus: string | null = null; + if (hero.invalid) heroStatus = t("invalid.value"); + else if (hero.completed) heroStatus = t("completed"); + + if (displayMode !== "advanced") { + return ( + + + {heroStatus && ( + + {heroStatus} + + )} + + {hero.event.label} + + + + + {formatOccurrence(hero, locale)} + + {options.showProgress && ( + + )} + + ); + } + + const setNotifications = async (enabled: boolean) => { + if (!enabled) { + setPreferences((current) => ({ ...current, notifications: false })); + return; + } + const permission = await requestBrowserNotificationPermission(); + setNotificationPermission(permission); + if (permission === "granted") setPreferences((current) => ({ ...current, notifications: true })); + }; + const eventColumns = width >= 700 ? 2 : 1; + + return ( + + + + + {heroStatus && {heroStatus}} + + {formatOccurrence(hero, locale)} + + + + {hero.event.label} + + + + {options.showProgress && } + + + + + + + + + {grouped.invalid.length > 0 && ( + + {t("invalid.description", { count: grouped.invalid.length })} + + )} + + + + ); +} + +const CountdownValue = ({ + state, + now, + locale, + showSeconds, + compact = false, +}: { + state: CountdownEventState; + now: Date; + locale: string; + showSeconds: boolean; + compact?: boolean; +}) => { + const t = useScopedI18n("widget.countdown"); + let value = t("invalid.value"); + if (state.completed) value = t("completed"); + else if (state.nextOccurrence) + value = formatCountdownDuration(state, now, showSeconds, locale, compact ? "narrow" : "short"); + return ( + + {value} + + ); +}; + +const ProgressBar = ({ + className, + state, + now, + label, +}: { + className?: string; + state: CountdownEventState; + now: Date; + label: string; +}) => { + const progress = getCountdownProgress(state, now); + if (progress === null) return null; + return ( + + ); +}; + +const EventSection = ({ + title, + states, + now, + locale, + showSeconds, + showProgress, + columns, +}: { + title: string; + states: CountdownEventState[]; + now: Date; + locale: string; + showSeconds: boolean; + showProgress: boolean; + columns: number; +}) => { + const t = useScopedI18n("widget.countdown"); + if (states.length === 0) return null; + return ( + + {title} + + {states.map((state) => ( + + + + + {state.event.label} + + {state.event.recurrence === "yearly" && {t("yearly")}} + + + + {showProgress && } + + {formatOccurrence(state, locale)} + + + + + ))} + + + ); +}; + +const AlertControls = ({ + disabled, + persistent, + preferences, + notificationPermission, + onNotificationsChange, +}: { + disabled: boolean; + persistent: boolean; + preferences: AlertPreferences; + notificationPermission: ReturnType; + onNotificationsChange: (enabled: boolean) => Promise; +}) => { + const t = useScopedI18n("widget.countdown.alert"); + if (!persistent) return null; + return ( + + void onNotificationsChange(event.currentTarget.checked)} + label={t("notifications")} + thumbIcon={preferences.notifications ? : } + /> + {notificationPermission === "denied" && ( + + {t("permissionDenied")} + + )} + + ); +}; + +const formatOccurrence = (state: CountdownEventState, locale: string) => { + const occurrence = state.nextOccurrence ?? state.previousOccurrence; + if (!occurrence) return state.event.timeZone; + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + timeZone: state.event.timeZone, + }).format(occurrence); +}; diff --git a/packages/widgets/src/countdown/display.ts b/packages/widgets/src/countdown/display.ts new file mode 100644 index 000000000..2e7c649f7 --- /dev/null +++ b/packages/widgets/src/countdown/display.ts @@ -0,0 +1,64 @@ +import type { CountdownEventState } from "./recurrence"; + +const secondMs = 1_000; +const minuteMs = 60 * secondMs; +const hourMs = 60 * minuteMs; +const dayMs = 24 * hourMs; + +export interface CountdownDurationPart { + unit: Intl.NumberFormatOptions["unit"]; + value: number; +} + +export const getCountdownDurationParts = ( + state: CountdownEventState, + now: Date, + showSeconds: boolean, +): CountdownDurationPart[] => { + const occurrence = state.nextOccurrence; + if (!occurrence) return []; + const remaining = Math.max(0, occurrence.getTime() - now.getTime()); + const days = Math.floor(remaining / dayMs); + const hours = Math.floor((remaining % dayMs) / hourMs); + const minutes = Math.floor((remaining % hourMs) / minuteMs); + const seconds = Math.floor((remaining % minuteMs) / secondMs); + + if (showSeconds) { + const parts: CountdownDurationPart[] = []; + if (days > 0) parts.push({ unit: "day", value: days }); + if (days > 0 || hours > 0) parts.push({ unit: "hour", value: hours }); + if (days > 0 || hours > 0 || minutes > 0) parts.push({ unit: "minute", value: minutes }); + parts.push({ unit: "second", value: seconds }); + return parts; + } + + if (days > 0) + return compactParts([ + { unit: "day", value: days }, + { unit: "hour", value: hours }, + ]); + if (hours > 0) + return compactParts([ + { unit: "hour", value: hours }, + { unit: "minute", value: minutes }, + ]); + if (minutes > 0) return [{ unit: "minute", value: minutes }]; + return [{ unit: "minute", value: 0 }]; +}; + +const compactParts = (parts: CountdownDurationPart[]) => { + const nonZero = parts.filter(({ value }) => value > 0); + return nonZero.length > 0 ? nonZero : parts.slice(0, 1); +}; + +export const formatCountdownDuration = ( + state: CountdownEventState, + now: Date, + showSeconds: boolean, + locale: string, + unitDisplay: Intl.NumberFormatOptions["unitDisplay"] = "short", +) => { + return getCountdownDurationParts(state, now, showSeconds) + .map(({ unit, value }) => new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay }).format(value)) + .join(" "); +}; diff --git a/packages/widgets/src/countdown/index.ts b/packages/widgets/src/countdown/index.ts new file mode 100644 index 000000000..204a5ec26 --- /dev/null +++ b/packages/widgets/src/countdown/index.ts @@ -0,0 +1,24 @@ +import { IconHourglass } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; +import { getTimeZoneOptions } from "../clock/world-clock"; + +const maximumCountdownEvents = 20; + +export const { definition, componentLoader } = createWidgetDefinition("countdown", { + icon: IconHourglass, + supportsAdvancedFocus: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + events: factory.dateTimeEventList({ + defaultValue: [], + maxValues: maximumCountdownEvents, + timeZoneOptions: getTimeZoneOptions(), + withDescription: true, + }), + showProgress: factory.switch({ defaultValue: true }), + showSeconds: factory.switch({ defaultValue: false, withDescription: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/countdown/recurrence.spec.ts b/packages/widgets/src/countdown/recurrence.spec.ts new file mode 100644 index 000000000..1d039379c --- /dev/null +++ b/packages/widgets/src/countdown/recurrence.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import type { OptionDateTimeEvent } from "../options"; +import { getCountdownDurationParts } from "./display"; +import { + getCountdownEventState, + getCountdownProgress, + resolveWallDateTimeString, + groupCountdownStates, +} from "./recurrence"; + +const event = (overrides: Partial = {}): OptionDateTimeEvent => ({ + id: "event", + label: "Event", + targetUtc: "2026-07-15T13:00:00.000Z", + timeZone: "America/New_York", + recurrence: "none", + ...overrides, +}); + +describe("countdown recurrence", () => { + it("preserves a yearly event's wall-clock time across DST offsets", () => { + const recurring = event({ recurrence: "yearly" }); + const winterState = getCountdownEventState(recurring, new Date("2027-01-01T00:00:00.000Z")); + + expect(winterState.nextOccurrence?.toISOString()).toBe("2027-07-15T13:00:00.000Z"); + }); + + it("skips non-leap years for a February 29 recurrence", () => { + const recurring = event({ + recurrence: "yearly", + targetUtc: "2024-02-29T12:00:00.000Z", + timeZone: "UTC", + }); + const state = getCountdownEventState(recurring, new Date("2025-01-01T00:00:00.000Z")); + + expect(state.nextOccurrence?.toISOString()).toBe("2028-02-29T12:00:00.000Z"); + expect(state.previousOccurrence?.toISOString()).toBe("2024-02-29T12:00:00.000Z"); + }); + + it("selects the first valid time after a DST gap", () => { + const resolved = resolveWallDateTimeString("2026-03-08 02:30:45", "America/New_York"); + expect(resolved?.toISOString()).toBe("2026-03-08T07:00:00.000Z"); + }); + + it("selects the earlier occurrence when a wall-clock time repeats", () => { + const resolved = resolveWallDateTimeString("2026-11-01 01:30:00", "America/New_York"); + expect(resolved?.toISOString()).toBe("2026-11-01T05:30:00.000Z"); + }); +}); + +describe("countdown ordering and progress", () => { + const now = new Date("2026-06-01T12:00:00.000Z"); + + it("preserves configured order within event groups", () => { + const states = [ + getCountdownEventState(event({ id: "later", targetUtc: "2026-06-03T12:00:00.000Z" }), now), + getCountdownEventState(event({ id: "older", targetUtc: "2026-05-01T12:00:00.000Z" }), now), + getCountdownEventState(event({ id: "sooner", targetUtc: "2026-06-02T12:00:00.000Z" }), now), + getCountdownEventState(event({ id: "newer", targetUtc: "2026-05-31T12:00:00.000Z" }), now), + ]; + + const grouped = groupCountdownStates(states); + expect(grouped.upcoming.map(({ event: item }) => item.id)).toEqual(["later", "sooner"]); + expect(grouped.completed.map(({ event: item }) => item.id)).toEqual(["older", "newer"]); + }); + + it("clamps one-time progress and omits it without a start", () => { + const withStart = getCountdownEventState( + event({ targetUtc: "2026-06-02T12:00:00.000Z", startUtc: "2026-05-31T12:00:00.000Z" }), + now, + ); + const withoutStart = getCountdownEventState(event({ targetUtc: "2026-06-02T12:00:00.000Z" }), now); + + expect(getCountdownProgress(withStart, now)).toBe(50); + expect(getCountdownProgress(withStart, new Date("2026-06-04T12:00:00.000Z"))).toBe(100); + expect(getCountdownProgress(withoutStart, now)).toBeNull(); + }); + + it("uses the previous and next occurrence for yearly progress", () => { + const state = getCountdownEventState( + event({ recurrence: "yearly", targetUtc: "2024-01-01T00:00:00.000Z", timeZone: "UTC" }), + new Date("2026-07-02T12:00:00.000Z"), + ); + const progress = getCountdownProgress(state, new Date("2026-07-02T12:00:00.000Z")); + + expect(progress).not.toBeNull(); + if (progress === null) throw new Error("Expected yearly progress"); + expect(progress).toBeGreaterThan(49); + expect(progress).toBeLessThan(51); + }); + + it("keeps seconds visible at every duration when enabled", () => { + const longState = getCountdownEventState(event({ targetUtc: "2026-06-03T13:00:00.000Z" }), now); + const shortState = getCountdownEventState(event({ targetUtc: "2026-06-02T11:30:45.000Z" }), now); + + expect(getCountdownDurationParts(longState, now, true).some(({ unit }) => unit === "second")).toBe(true); + expect(getCountdownDurationParts(shortState, now, true).some(({ unit }) => unit === "second")).toBe(true); + expect(getCountdownDurationParts(longState, now, false).some(({ unit }) => unit === "second")).toBe(false); + }); +}); diff --git a/packages/widgets/src/countdown/recurrence.ts b/packages/widgets/src/countdown/recurrence.ts new file mode 100644 index 000000000..8d161b05e --- /dev/null +++ b/packages/widgets/src/countdown/recurrence.ts @@ -0,0 +1,226 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; + +import type { OptionDateTimeEvent } from "../options"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const minuteMs = 60_000; +const dayMs = 86_400_000; + +export interface WallDateTime { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +export interface CountdownEventState { + event: OptionDateTimeEvent; + nextOccurrence: Date | null; + previousOccurrence: Date | null; + completed: boolean; + invalid: boolean; +} + +export const isCountdownTimeZoneSupported = (timeZone: string) => { + try { + new Intl.DateTimeFormat("en", { timeZone }).format(0); + return true; + } catch { + return false; + } +}; + +export const parseWallDateTime = (value: string): WallDateTime | null => { + const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); + if (!match) return null; + const parts: WallDateTime = { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4]), + minute: Number(match[5]), + second: Number(match[6] ?? 0), + }; + const validationDate = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second), + ); + if ( + validationDate.getUTCFullYear() !== parts.year || + validationDate.getUTCMonth() !== parts.month - 1 || + validationDate.getUTCDate() !== parts.day || + validationDate.getUTCHours() !== parts.hour || + validationDate.getUTCMinutes() !== parts.minute || + validationDate.getUTCSeconds() !== parts.second + ) { + return null; + } + return parts; +}; + +export const formatWallDateTime = (instant: string | Date, timeZone: string) => { + if (!isCountdownTimeZoneSupported(timeZone)) return ""; + const value = dayjs(instant); + if (!value.isValid()) return ""; + return value.tz(timeZone).format("YYYY-MM-DD HH:mm:ss"); +}; + +const wallDateTimeEquals = (value: WallDateTime, candidate: dayjs.Dayjs) => + candidate.year() === value.year && + candidate.month() + 1 === value.month && + candidate.date() === value.day && + candidate.hour() === value.hour && + candidate.minute() === value.minute && + candidate.second() === value.second; + +const resolveExactWallDateTime = (value: WallDateTime, timeZone: string): Date[] => { + const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute, value.second); + const offsets = new Set(); + for (const dayOffset of [-2, -1, 0, 1, 2]) { + offsets.add( + dayjs + .utc(naiveUtc + dayOffset * dayMs) + .tz(timeZone) + .utcOffset(), + ); + } + + const matches: Date[] = []; + for (const offset of offsets) { + const candidate = new Date(naiveUtc - offset * minuteMs); + if (wallDateTimeEquals(value, dayjs(candidate).tz(timeZone))) matches.push(candidate); + } + return matches.toSorted((left, right) => left.getTime() - right.getTime()); +}; + +/** Resolves a local wall-clock value, preferring the earlier repeated time and the first valid time after a DST gap. */ +export const resolveWallDateTime = (value: WallDateTime, timeZone: string): Date | null => { + if (!isCountdownTimeZoneSupported(timeZone)) return null; + const exactMatches = resolveExactWallDateTime(value, timeZone); + if (exactMatches[0]) return exactMatches[0]; + + const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute); + for (let minuteOffset = 1; minuteOffset <= 180; minuteOffset += 1) { + const candidateWallDate = new Date(naiveUtc + minuteOffset * minuteMs); + const candidateWall: WallDateTime = { + year: candidateWallDate.getUTCFullYear(), + month: candidateWallDate.getUTCMonth() + 1, + day: candidateWallDate.getUTCDate(), + hour: candidateWallDate.getUTCHours(), + minute: candidateWallDate.getUTCMinutes(), + second: candidateWallDate.getUTCSeconds(), + }; + const matches = resolveExactWallDateTime(candidateWall, timeZone); + if (matches[0]) return matches[0]; + } + return null; +}; + +export const resolveWallDateTimeString = (value: string, timeZone: string) => { + const wallDateTime = parseWallDateTime(value); + if (!wallDateTime) return null; + return resolveWallDateTime(wallDateTime, timeZone); +}; + +const isLeapYear = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const occurrenceForYear = (target: WallDateTime, year: number, timeZone: string) => { + if (target.month === 2 && target.day === 29 && !isLeapYear(year)) return null; + return resolveWallDateTime({ ...target, year }, timeZone); +}; + +const findYearlyOccurrence = ( + target: WallDateTime, + timeZone: string, + startYear: number, + direction: 1 | -1, + predicate: (date: Date) => boolean, +) => { + for (let index = 0; index < 12; index += 1) { + const occurrence = occurrenceForYear(target, startYear + index * direction, timeZone); + if (occurrence && predicate(occurrence)) return occurrence; + } + return null; +}; + +export const getCountdownEventState = (event: OptionDateTimeEvent, now: Date): CountdownEventState => { + const target = dayjs(event.targetUtc); + if (!target.isValid() || !isCountdownTimeZoneSupported(event.timeZone)) { + return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true }; + } + + if (event.recurrence === "none") { + const targetDate = target.toDate(); + const completed = targetDate.getTime() <= now.getTime(); + return { + event, + nextOccurrence: completed ? null : targetDate, + previousOccurrence: completed ? targetDate : null, + completed, + invalid: false, + }; + } + + const targetWall = parseWallDateTime(formatWallDateTime(event.targetUtc, event.timeZone)); + if (!targetWall) { + return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true }; + } + const currentYear = dayjs(now).tz(event.timeZone).year(); + const nextOccurrence = findYearlyOccurrence( + targetWall, + event.timeZone, + currentYear, + 1, + (occurrence) => occurrence.getTime() > now.getTime(), + ); + const previousOccurrence = findYearlyOccurrence( + targetWall, + event.timeZone, + currentYear, + -1, + (occurrence) => occurrence.getTime() <= now.getTime(), + ); + return { + event, + nextOccurrence, + previousOccurrence, + completed: false, + invalid: nextOccurrence === null, + }; +}; + +export const getCountdownProgress = (state: CountdownEventState, now: Date): number | null => { + let start: Date | null = null; + let end: Date | null = null; + + if (state.event.recurrence === "yearly") { + start = state.previousOccurrence; + end = state.nextOccurrence; + } else if (state.event.startUtc) { + const parsedStart = dayjs(state.event.startUtc); + const parsedEnd = dayjs(state.event.targetUtc); + if (parsedStart.isValid() && parsedEnd.isValid()) { + start = parsedStart.toDate(); + end = parsedEnd.toDate(); + } + } + + if (!start || !end || end.getTime() <= start.getTime()) return null; + const progress = ((now.getTime() - start.getTime()) / (end.getTime() - start.getTime())) * 100; + return Math.min(100, Math.max(0, progress)); +}; + +export const groupCountdownStates = (states: CountdownEventState[]) => { + const upcoming = states.filter((state) => state.nextOccurrence !== null && !state.invalid); + const completed = states.filter((state) => state.completed && state.previousOccurrence !== null && !state.invalid); + const invalid = states.filter((state) => state.invalid); + return { upcoming, completed, invalid }; +}; + +export const getLatestReachedOccurrence = (event: OptionDateTimeEvent, now: Date) => + getCountdownEventState(event, now).previousOccurrence; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index d6565a702..060185a39 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -63,6 +63,10 @@ import * as beszelAlerts from "./beszel-alerts"; import * as beszelSystemStats from "./beszel-system-stats"; import * as video from "./video"; import * as weather from "./weather"; +import * as today from "./today"; +import * as airQuality from "./air-quality"; +import * as countdown from "./countdown"; +import * as timer from "./timer"; import * as customApi from "./custom-api"; import * as assistant from "./assistant"; import * as wud from "./wud"; @@ -94,6 +98,10 @@ export { AssistantWidgetRendererProvider } from "./assistant/context"; export const widgetImports = { clock, weather, + today, + airQuality, + countdown, + timer, app, archiveTeamWarrior, anchorNote, diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index 2cb9a373c..c8a1f319f 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -17,6 +17,8 @@ import { widgetQueryRefetchIntervals } from "./refetch-intervals"; import { widgetCatalogIcons } from "./catalog"; import { getWidgetQueryKeys } from "./definition"; +const serializePollingPolicy = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry); + describe("widget manifest promise stability", () => { it("returns the same module promise for every render", () => { expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock")); @@ -145,9 +147,8 @@ describe("widget manifest promise stability", () => { } } - const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry); - expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual( - [...expectedByQueryKey.values()].map(serializePolicy).toSorted(), + expect(widgetQueryRefetchIntervals.map(serializePollingPolicy).toSorted()).toEqual( + [...expectedByQueryKey.values()].map(serializePollingPolicy).toSorted(), ); }); diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 3e12ea75b..9d1cb6e6d 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -24,6 +24,10 @@ export type WidgetResources = { const moduleLoaders: Record Promise> = { clock: () => import("./clock") as Promise, weather: () => import("./weather") as Promise, + today: () => import("./today") as Promise, + airQuality: () => import("./air-quality") as Promise, + countdown: () => import("./countdown") as Promise, + timer: () => import("./timer") as Promise, app: () => import("./app") as Promise, assistant: () => import("./assistant") as Promise, archiveTeamWarrior: () => import("./archive-team-warrior") as Promise, @@ -85,6 +89,10 @@ const moduleLoaders: Record Promise> = { const componentLoaders: Record Promise> = { clock: () => import("./clock/component") as Promise, weather: () => import("./weather/component") as Promise, + today: () => import("./today/component") as Promise, + airQuality: () => import("./air-quality/component") as Promise, + countdown: () => import("./countdown/component") as Promise, + timer: () => import("./timer/component") as Promise, app: () => import("./app/component") as Promise, assistant: () => import("./assistant/component") as Promise, archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise, diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 80e0113c5..74e20a83c 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -52,6 +52,7 @@ interface SelectInput extends CommonIn > { options: TOptions; searchable?: boolean; + withPlaceholder?: boolean; } interface DynamicSelectInput extends CommonInput { @@ -107,6 +108,34 @@ interface SliderInput extends CommonInput { step?: number; } +export interface OptionTimezone { + id: string; + label: string; + timeZone: string; +} + +interface TimezoneListInput extends CommonInput { + maxValues?: number; + presets?: readonly OptionTimezone[]; + timeZoneOptions: readonly { value: string; label: string }[]; +} + +export type DateTimeEventRecurrence = "none" | "yearly"; + +export interface OptionDateTimeEvent { + id: string; + label: string; + targetUtc: string; + timeZone: string; + startUtc?: string; + recurrence: DateTimeEventRecurrence; +} + +interface DateTimeEventListInput extends CommonInput { + maxValues?: number; + timeZoneOptions: readonly { value: string; label: string }[]; +} + export interface OptionLocation { name: string; latitude: number; @@ -163,6 +192,7 @@ const optionsFactory = { options: input.options, searchable: input.searchable ?? false, withDescription: input.withDescription ?? false, + withPlaceholder: input.withPlaceholder ?? false, }), number: (input: NumberInput) => ({ type: "number" as const, @@ -192,6 +222,72 @@ const optionsFactory = { longitude: z.number(), }), }), + timezoneList: (input: TimezoneListInput) => { + const maxValues = input.maxValues ?? 6; + return { + type: "timezoneList" as const, + defaultValue: input.defaultValue ?? [], + maxValues, + presets: input.presets ?? [], + timeZoneOptions: input.timeZoneOptions, + withDescription: input.withDescription ?? false, + validate: z + .array( + z.object({ + id: z.string().min(1), + label: z.string().trim().min(1).max(64), + timeZone: z.string().min(1), + }), + ) + .max(maxValues) + .superRefine((values, ctx) => { + const ids = new Set(); + const timeZones = new Set(); + + values.forEach((value, index) => { + if (ids.has(value.id)) { + ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" }); + } + if (timeZones.has(value.timeZone)) { + ctx.addIssue({ code: "custom", path: [index, "timeZone"], message: "Duplicate timezone" }); + } + ids.add(value.id); + timeZones.add(value.timeZone); + }); + }), + }; + }, + dateTimeEventList: (input: DateTimeEventListInput) => { + const maxValues = input.maxValues ?? 20; + const dateTimeEventSchema = z.object({ + id: z.string().min(1), + label: z.string().trim().min(1).max(64), + targetUtc: z.iso.datetime(), + timeZone: z.string().min(1), + startUtc: z.iso.datetime().optional(), + recurrence: z.enum(["none", "yearly"]), + }); + + return { + type: "dateTimeEventList" as const, + defaultValue: input.defaultValue ?? [], + maxValues, + timeZoneOptions: input.timeZoneOptions, + withDescription: input.withDescription ?? false, + validate: z + .array(dateTimeEventSchema) + .max(maxValues) + .superRefine((values, ctx) => { + const ids = new Set(); + values.forEach((value, index) => { + if (ids.has(value.id)) { + ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" }); + } + ids.add(value.id); + }); + }), + }; + }, multiText: (input?: CommonInput & { validate?: ZodType }) => ({ type: "multiText" as const, defaultValue: input?.defaultValue ?? [], diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index d6a5160cc..7f60c3154 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -13,6 +13,8 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 }, { queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 }, { queryKey: [["widget", "tracearr"]], intervalSeconds: 10 }, + { queryKey: [["widget", "weather", "atLocation"]], intervalSeconds: 600 }, + { queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 }, { queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null }, { queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null }, { queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null }, diff --git a/packages/widgets/src/timer/component.module.css b/packages/widgets/src/timer/component.module.css new file mode 100644 index 000000000..0f1d68fea --- /dev/null +++ b/packages/widgets/src/timer/component.module.css @@ -0,0 +1,73 @@ +.root { + container-type: size; + overflow: hidden; +} + +.hero { + background: linear-gradient(145deg, var(--mantine-primary-color-light), var(--mantine-color-secondaryColor-light)); +} + +.time { + font-variant-numeric: tabular-nums; + letter-spacing: -0.045em; +} + +.root[data-needs-attention="true"], +.hero[data-needs-attention="true"] { + animation: timer-border-alert 300ms ease-out 5; +} + +@keyframes timer-border-alert { + 0% { + box-shadow: + inset 0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0), + 0 0 0 0 alpha(var(--mantine-color-secondaryColor-filled), 0); + } + + 35% { + box-shadow: + inset 0 0 0 2px alpha(var(--mantine-color-secondaryColor-filled), 0.9), + 0 0 22px 5px alpha(var(--mantine-color-secondaryColor-filled), 0.65); + } + + 100% { + box-shadow: + inset 0 0 0 1px alpha(var(--mantine-color-secondaryColor-filled), 0), + 0 0 28px 7px alpha(var(--mantine-color-secondaryColor-filled), 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .root[data-needs-attention="true"], + .hero[data-needs-attention="true"] { + animation: none; + box-shadow: inset 0 0 0 2px var(--mantine-color-secondaryColor-filled); + } +} + +@media (forced-colors: active) { + .root[data-needs-attention="true"], + .hero[data-needs-attention="true"] { + animation: none; + outline: 2px solid Highlight; + outline-offset: 2px; + } +} + +@container (max-height: 125px) { + .root [role="progressbar"] { + display: none; + } +} + +@container (max-width: 210px) { + .completedFocus { + display: none; + } +} + +@container (max-height: 110px) { + .phaseBadge { + display: none; + } +} diff --git a/packages/widgets/src/timer/component.tsx b/packages/widgets/src/timer/component.tsx new file mode 100644 index 000000000..482519fab --- /dev/null +++ b/packages/widgets/src/timer/component.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Alert, + Badge, + Group, + Paper, + Progress, + ScrollArea, + SimpleGrid, + Stack, + Switch, + Text, + Tooltip, +} from "@mantine/core"; +import { IconBell, IconPlayerPause, IconPlayerPlay, IconRefresh, IconSettings, IconVolume } from "@tabler/icons-react"; +import { useTiks } from "@rexa-developer/tiks/react"; + +import { useScopedI18n } from "@homarr/translation/client"; + +import { + claimBrowserAlertOccurrence, + getBrowserNotificationPermission, + requestBrowserNotificationPermission, + showBrowserNotification, +} from "../common/browser-alert"; +import { useLocalWidgetState } from "../common/use-local-widget-state"; +import { useWidgetNow } from "../common/use-widget-now"; +import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; +import { + createTimerRuntime, + getRemainingMs, + isTimerRuntimeState, + migrateTimerRuntimeState, + pauseTimerRuntime, + reconcileTimerRuntime, + resetTimerRuntime, + setTimerAlertPreference, + startTimerRuntime, + synchronizeTimerRuntime, +} from "./runtime"; +import type { TimerDurations, TimerRuntimeState } from "./runtime"; + +const completionSoundCount = 5; +const completionSoundIntervalMs = 300; + +export default function TimerWidget({ + boardId, + displayMode, + height, + isEditMode, + itemId, + options, + width, +}: WidgetComponentProps<"timer">) { + const now = useWidgetNow("second"); + const t = useScopedI18n("widget.timer"); + const { click: playResetSound, toggle: playRunningSound } = useTiks({ + theme: "soft", + volume: 0.12, + respectReducedMotion: true, + }); + const { notify: playCompletionSound } = useTiks({ + theme: "glass", + volume: 0.12, + respectReducedMotion: true, + }); + const durations = useMemo( + () => ({ + timerMinutes: options.timerMinutes, + focusMinutes: options.focusMinutes, + shortBreakMinutes: options.shortBreakMinutes, + longBreakMinutes: options.longBreakMinutes, + sessionsBeforeLongBreak: options.sessionsBeforeLongBreak, + autoStartBreaks: options.autoStartBreaks, + autoStartFocus: options.autoStartFocus, + }), + [ + options.autoStartBreaks, + options.autoStartFocus, + options.focusMinutes, + options.longBreakMinutes, + options.sessionsBeforeLongBreak, + options.shortBreakMinutes, + options.timerMinutes, + ], + ); + const storagePrefix = boardId && itemId ? `homarr:timer:${boardId}:${itemId}` : undefined; + const [runtime, setRuntime] = useLocalWidgetState({ + key: storagePrefix ? `${storagePrefix}:state` : undefined, + version: 3, + defaultValue: () => createTimerRuntime(options.mode, durations), + validate: isTimerRuntimeState, + migrate: migrateTimerRuntimeState, + }); + const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission()); + const historyInitializedRef = useRef(false); + const handledHistoryIdRef = useRef(null); + const completionSoundTimeoutRef = useRef(null); + const stopCompletionSoundSequence = useCallback(() => { + if (completionSoundTimeoutRef.current === null) return; + window.clearTimeout(completionSoundTimeoutRef.current); + completionSoundTimeoutRef.current = null; + }, []); + + useEffect(() => { + setRuntime((current) => synchronizeTimerRuntime(current, options.mode, durations)); + }, [durations, options.mode, setRuntime]); + + useEffect(() => { + if (!now) return; + setRuntime((current) => reconcileTimerRuntime(current, now.getTime(), durations)); + }, [durations, now, setRuntime]); + + useEffect(() => { + const latest = runtime.history.at(-1); + if (!historyInitializedRef.current) { + historyInitializedRef.current = true; + handledHistoryIdRef.current = latest?.id ?? null; + return; + } + if (!latest || handledHistoryIdRef.current === latest.id) return; + handledHistoryIdRef.current = latest.id; + + const alertKey = storagePrefix ? `${storagePrefix}:alerted-completions` : undefined; + if (alertKey && !claimBrowserAlertOccurrence(alertKey, latest.id)) return; + if (runtime.alerts.notifications) { + showBrowserNotification({ + title: t("alert.title"), + body: t("alert.completed", { phase: t(`phase.${latest.phase}`) }), + tag: latest.id, + }); + } + if (!runtime.alerts.sound) return; + stopCompletionSoundSequence(); + playCompletionSound(); + if (!runtime.awaitingManualStart) return; + + let remainingSounds = completionSoundCount - 1; + const playNextSound = () => { + completionSoundTimeoutRef.current = window.setTimeout(() => { + completionSoundTimeoutRef.current = null; + playCompletionSound(); + remainingSounds -= 1; + if (remainingSounds > 0) playNextSound(); + }, completionSoundIntervalMs); + }; + playNextSound(); + }, [ + playCompletionSound, + runtime.alerts.notifications, + runtime.alerts.sound, + runtime.awaitingManualStart, + runtime.history, + stopCompletionSoundSequence, + storagePrefix, + t, + ]); + + useEffect(() => { + if (runtime.awaitingManualStart && runtime.alerts.sound) return; + stopCompletionSoundSequence(); + }, [runtime.alerts.sound, runtime.awaitingManualStart, stopCompletionSoundSequence]); + + useEffect(() => stopCompletionSoundSequence, [stopCompletionSoundSequence]); + + if (!now) { + return ( + + — + + ); + } + + const nowMs = now.getTime(); + const remainingMs = getRemainingMs(runtime, nowMs); + const phaseDurationMs = runtime.totalDurationMs; + const progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100)); + const controlSize = getTimerControlSize(width, height, displayMode); + const toggleRunning = () => { + if (runtime.alerts.sound) playRunningSound(runtime.status !== "running"); + if (runtime.status === "running") { + setRuntime((current) => pauseTimerRuntime(current, nowMs)); + return; + } + setRuntime((current) => startTimerRuntime(current, nowMs, durations)); + }; + const reset = () => { + if (runtime.alerts.sound) playResetSound(); + setRuntime((current) => resetTimerRuntime(current, durations)); + }; + const setNotifications = async (enabled: boolean) => { + if (!enabled) { + setRuntime((current) => setTimerAlertPreference(current, "notifications", false)); + return; + } + const permission = await requestBrowserNotificationPermission(); + setNotificationPermission(permission); + if (permission === "granted") { + setRuntime((current) => setTimerAlertPreference(current, "notifications", true)); + } + }; + const setSound = (enabled: boolean) => { + playRunningSound(enabled); + setRuntime((current) => setTimerAlertPreference(current, "sound", enabled)); + }; + + const shared = { + disabled: isEditMode, + controlSize, + onReset: reset, + onToggleRunning: toggleRunning, + progress, + remainingMs, + runtime, + }; + + if (displayMode !== "advanced") return ; + + return ( + + ); +} + +type TimerControlSize = "sm" | "md" | "lg"; + +interface TimerDisplayProps { + controlSize: TimerControlSize; + disabled: boolean; + onReset: () => void; + onToggleRunning: () => void; + progress: number; + remainingMs: number; + runtime: TimerRuntimeState; +} + +const CompactTimer = ({ + controlSize, + disabled, + onReset, + onToggleRunning, + progress, + remainingMs, + runtime, +}: TimerDisplayProps) => { + const t = useScopedI18n("widget.timer"); + const phaseLabel = getTimerPhaseLabel(runtime, t); + return ( + + + + {phaseLabel} + + {runtime.mode === "pomodoro" && ( + + {t("compact.completedFocus", { count: runtime.completedFocusSessions })} + + )} + + + {formatTimerDuration(remainingMs)} + + + + + ); +}; + +interface AdvancedTimerProps extends TimerDisplayProps { + durations: TimerDurations; + notificationPermission: ReturnType; + onNotificationsChange: (enabled: boolean) => Promise; + onSoundChange: (enabled: boolean) => void; +} + +const AdvancedTimer = ({ + controlSize, + disabled, + durations, + notificationPermission, + onNotificationsChange, + onReset, + onSoundChange, + onToggleRunning, + progress, + remainingMs, + runtime, +}: AdvancedTimerProps) => { + const t = useScopedI18n("widget.timer"); + const phaseLabel = getTimerPhaseLabel(runtime, t); + return ( + + + + + + {phaseLabel} + + + {formatTimerDuration(remainingMs)} + + + + + + + {runtime.mode === "pomodoro" && } + + + + + + {t("advanced.settings")} + + + + {t("advanced.settingsHint")} + + + + + + + {t("advanced.localAlerts")} + + + void onNotificationsChange(event.currentTarget.checked)} + /> + onSoundChange(event.currentTarget.checked)} + thumbIcon={} + /> + + {t("advanced.localAlertsHint")} + + {notificationPermission === "denied" && {t("advanced.notificationsDenied")}} + + + + + + ); +}; + +const TimerControls = ({ + controlSize, + disabled, + isRunning, + onReset, + onToggleRunning, +}: { + controlSize: TimerControlSize; + disabled: boolean; + isRunning: boolean; + onReset: () => void; + onToggleRunning: () => void; +}) => { + const t = useScopedI18n("widget.timer"); + const toggleLabel = isRunning ? t("action.pause") : t("action.start"); + return ( + + + + {isRunning ? : } + + + + + + + + + ); +}; + +type ScopedTimerTranslator = ReturnType>; + +const getTimerPhaseLabel = (runtime: TimerRuntimeState, t: ScopedTimerTranslator) => { + const phase = t(`phase.${runtime.phase}`); + if (!runtime.awaitingManualStart) return phase; + if (runtime.mode === "timer") return t("attention.timerComplete"); + return t("attention.phaseReady", { phase }); +}; + +const getTimerControlSize = ( + width: number, + height: number, + displayMode: "compact" | "advanced" | undefined, +): TimerControlSize => { + if (displayMode === "advanced") return "lg"; + if (width < 150 || height < 115) return "sm"; + if (width < 220 || height < 150) return "md"; + return "lg"; +}; + +const PomodoroSequence = ({ runtime, durations }: { runtime: TimerRuntimeState; durations: TimerDurations }) => { + const t = useScopedI18n("widget.timer"); + return ( + + + {t("advanced.sequence")} + + + {Array.from({ length: durations.sessionsBeforeLongBreak }, (_, index) => ( + + + {t("advanced.focusSession", { session: index + 1 })} + + + {t("phase.shortBreak")} + + + ))} + + {t("phase.longBreak")} + + + + ); +}; + +const SettingsSummary = ({ mode, durations }: { mode: TimerRuntimeState["mode"]; durations: TimerDurations }) => { + const t = useScopedI18n("widget.timer"); + if (mode === "timer") return ; + return ( + + + + + + + ); +}; + +const SettingLine = ({ label, value }: { label: string; value: number }) => ( + + + {label} + + + {value} + + +); + +export const formatTimerDuration = (durationMs: number): string => { + const totalSeconds = Math.max(0, Math.ceil(durationMs / 1_000)); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + const minuteText = String(minutes).padStart(2, "0"); + const secondText = String(seconds).padStart(2, "0"); + if (hours === 0) return `${minuteText}:${secondText}`; + return `${String(hours).padStart(2, "0")}:${minuteText}:${secondText}`; +}; diff --git a/packages/widgets/src/timer/index.ts b/packages/widgets/src/timer/index.ts new file mode 100644 index 000000000..c5fbf26fb --- /dev/null +++ b/packages/widgets/src/timer/index.ts @@ -0,0 +1,54 @@ +import { IconClockPlay } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("timer", { + icon: IconClockPlay, + supportsAdvancedFocus: true, + createOptions() { + return optionsBuilder.from( + (factory) => ({ + mode: factory.select({ + defaultValue: "pomodoro", + options: [ + { value: "timer", label: (t) => t("widget.timer.option.mode.options.timer") }, + { value: "pomodoro", label: (t) => t("widget.timer.option.mode.options.pomodoro") }, + ], + }), + timerMinutes: factory.number({ + defaultValue: 10, + validate: z.number().int().min(1).max(1440), + }), + focusMinutes: factory.number({ + defaultValue: 25, + validate: z.number().int().min(1).max(180), + }), + shortBreakMinutes: factory.number({ + defaultValue: 5, + validate: z.number().int().min(1).max(60), + }), + longBreakMinutes: factory.number({ + defaultValue: 15, + validate: z.number().int().min(1).max(180), + }), + sessionsBeforeLongBreak: factory.number({ + defaultValue: 4, + validate: z.number().int().min(1).max(12), + }), + autoStartBreaks: factory.switch({ defaultValue: false }), + autoStartFocus: factory.switch({ defaultValue: false }), + }), + { + timerMinutes: { shouldHide: ({ mode }) => mode !== "timer" }, + focusMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + shortBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + longBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + sessionsBeforeLongBreak: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + autoStartBreaks: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + autoStartFocus: { shouldHide: ({ mode }) => mode !== "pomodoro" }, + }, + ); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/timer/runtime.spec.ts b/packages/widgets/src/timer/runtime.spec.ts new file mode 100644 index 000000000..bba01ea1a --- /dev/null +++ b/packages/widgets/src/timer/runtime.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + createTimerRuntime, + getRemainingMs, + isTimerRuntimeState, + pauseTimerRuntime, + reconcileTimerRuntime, + startTimerRuntime, + synchronizeTimerRuntime, +} from "./runtime"; + +const durations = { + timerMinutes: 10, + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + sessionsBeforeLongBreak: 2, + autoStartBreaks: false, + autoStartFocus: false, +}; + +describe("timer runtime", () => { + it("uses a persisted deadline and preserves a paused remainder", () => { + const initial = createTimerRuntime("timer", durations); + const running = startTimerRuntime(initial, 1_000, durations); + expect(running.deadline).toBe(601_000); + expect(getRemainingMs(running, 61_000)).toBe(540_000); + + const paused = pauseTimerRuntime(running, 61_000); + expect(paused).toMatchObject({ status: "paused", remainingMs: 540_000, deadline: null }); + expect(startTimerRuntime(paused, 100_000, durations).deadline).toBe(640_000); + }); + + it("moves through short and long breaks using completed focus sessions", () => { + const firstFocus = startTimerRuntime(createTimerRuntime("pomodoro", durations), 0, durations); + const shortBreak = reconcileTimerRuntime(firstFocus, 25 * 60_000, durations); + expect(shortBreak).toMatchObject({ phase: "shortBreak", status: "idle", completedFocusSessions: 1 }); + + const secondFocus = startTimerRuntime( + { ...shortBreak, phase: "focus", remainingMs: 25 * 60_000 }, + 2_000_000, + durations, + ); + const longBreak = reconcileTimerRuntime(secondFocus, 3_500_000, durations); + expect(longBreak).toMatchObject({ phase: "longBreak", status: "idle", completedFocusSessions: 2 }); + }); + + it("catches up chained automatic phases from their original deadlines", () => { + const automatic = { + ...durations, + focusMinutes: 1, + shortBreakMinutes: 1, + autoStartBreaks: true, + autoStartFocus: true, + }; + const running = startTimerRuntime(createTimerRuntime("pomodoro", automatic), 0, automatic); + const reconciled = reconcileTimerRuntime(running, 125_000, automatic); + + expect(reconciled).toMatchObject({ phase: "focus", status: "running", completedFocusSessions: 1 }); + expect(reconciled.deadline).toBe(180_000); + expect(reconciled.history).toHaveLength(2); + }); + + it("keeps only the latest 20 completions", () => { + let state = startTimerRuntime(createTimerRuntime("timer", { ...durations, timerMinutes: 1 }), 0, durations); + for (let index = 1; index <= 25; index += 1) { + state = reconcileTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 }); + state = startTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 }); + } + expect(state.history).toHaveLength(20); + }); + + it("resets only live state when the permanent mode changes", () => { + const initial = createTimerRuntime("timer", durations); + const withHistory = { + ...initial, + history: [{ id: "timer:1", phase: "timer" as const, completedAt: 1, durationMs: 1 }], + }; + const synchronized = synchronizeTimerRuntime(withHistory, "pomodoro", durations); + expect(synchronized).toMatchObject({ mode: "pomodoro", phase: "focus", status: "idle" }); + expect(synchronized.history).toEqual(withHistory.history); + }); + + it("rejects inconsistent stored deadlines and modes", () => { + const valid = createTimerRuntime("pomodoro", durations); + expect(isTimerRuntimeState(valid)).toBe(true); + expect(isTimerRuntimeState({ ...valid, status: "running", deadline: null })).toBe(false); + expect(isTimerRuntimeState({ ...valid, phase: "timer" })).toBe(false); + }); +}); diff --git a/packages/widgets/src/timer/runtime.ts b/packages/widgets/src/timer/runtime.ts new file mode 100644 index 000000000..2189780e0 --- /dev/null +++ b/packages/widgets/src/timer/runtime.ts @@ -0,0 +1,287 @@ +export type TimerMode = "timer" | "pomodoro"; +export type TimerPhase = "timer" | "focus" | "shortBreak" | "longBreak"; +export type TimerStatus = "idle" | "running" | "paused"; + +export interface TimerDurations { + timerMinutes: number; + focusMinutes: number; + shortBreakMinutes: number; + longBreakMinutes: number; + sessionsBeforeLongBreak: number; + autoStartBreaks: boolean; + autoStartFocus: boolean; +} + +export interface TimerHistoryEntry { + id: string; + phase: TimerPhase; + completedAt: number; + durationMs: number; +} + +export interface TimerAlertPreferences { + notifications: boolean; + sound: boolean; +} + +export interface TimerRuntimeState { + mode: TimerMode; + phase: TimerPhase; + status: TimerStatus; + remainingMs: number; + totalDurationMs: number; + deadline: number | null; + completedFocusSessions: number; + history: TimerHistoryEntry[]; + alerts: TimerAlertPreferences; + awaitingManualStart: boolean; +} + +const maximumHistoryEntries = 20; +const maximumReconciledPhases = 100; +const maximumDurationMs = 24 * 60 * 60_000; + +export const createTimerRuntime = (mode: TimerMode, durations: TimerDurations): TimerRuntimeState => { + const phase = getInitialPhase(mode); + return { + mode, + phase, + status: "idle", + remainingMs: getPhaseDurationMs(phase, durations), + totalDurationMs: getPhaseDurationMs(phase, durations), + deadline: null, + completedFocusSessions: 0, + history: [], + alerts: { notifications: false, sound: true }, + awaitingManualStart: false, + }; +}; + +export const isTimerRuntimeState = (value: unknown): value is TimerRuntimeState => { + if (!isRecord(value)) return false; + if (value.mode !== "timer" && value.mode !== "pomodoro") return false; + if (!isTimerPhase(value.phase) || !isTimerStatus(value.status)) return false; + if (value.mode === "timer" && value.phase !== "timer") return false; + if (value.mode === "pomodoro" && value.phase === "timer") return false; + if (!isFiniteNumber(value.remainingMs) || value.remainingMs < 0) return false; + if (!isFiniteNumber(value.totalDurationMs) || value.totalDurationMs <= 0) return false; + if (value.totalDurationMs > maximumDurationMs || value.remainingMs > value.totalDurationMs) return false; + if (value.deadline !== null && !isFiniteNumber(value.deadline)) return false; + if (value.status === "running" && value.deadline === null) return false; + if (value.status !== "running" && value.deadline !== null) return false; + if (!isFiniteNumber(value.completedFocusSessions) || !Number.isInteger(value.completedFocusSessions)) return false; + if (value.completedFocusSessions < 0 || value.completedFocusSessions > 12) return false; + if (!Array.isArray(value.history) || value.history.length > maximumHistoryEntries) return false; + if (!value.history.every(isTimerHistoryEntry)) return false; + if (!isRecord(value.alerts)) return false; + if (typeof value.alerts.notifications !== "boolean" || typeof value.alerts.sound !== "boolean") return false; + return typeof value.awaitingManualStart === "boolean"; +}; + +export const migrateTimerRuntimeState = (value: unknown, storedVersion: number): TimerRuntimeState | undefined => { + if ((storedVersion !== 1 && storedVersion !== 2) || !isRecord(value) || !isRecord(value.alerts)) return undefined; + let awaitingManualStart = false; + if (storedVersion === 2 && typeof value.awaitingManualStart === "boolean") { + awaitingManualStart = value.awaitingManualStart; + } + const migrated = { + ...value, + alerts: { ...value.alerts, sound: true }, + awaitingManualStart, + }; + if (!isTimerRuntimeState(migrated)) return undefined; + return migrated; +}; + +export const getPhaseDurationMs = (phase: TimerPhase, durations: TimerDurations): number => { + if (phase === "timer") return durations.timerMinutes * 60_000; + if (phase === "focus") return durations.focusMinutes * 60_000; + if (phase === "shortBreak") return durations.shortBreakMinutes * 60_000; + return durations.longBreakMinutes * 60_000; +}; + +export const getRemainingMs = (state: TimerRuntimeState, now: number): number => { + if (state.status !== "running" || state.deadline === null) return state.remainingMs; + return Math.max(0, state.deadline - now); +}; + +export const startTimerRuntime = ( + state: TimerRuntimeState, + now: number, + durations: TimerDurations, +): TimerRuntimeState => { + if (state.status === "running") return state; + const remainingMs = state.remainingMs > 0 ? state.remainingMs : getPhaseDurationMs(state.phase, durations); + const totalDurationMs = state.remainingMs > 0 ? state.totalDurationMs : remainingMs; + return { + ...state, + status: "running", + remainingMs, + totalDurationMs, + deadline: now + remainingMs, + awaitingManualStart: false, + }; +}; + +export const pauseTimerRuntime = (state: TimerRuntimeState, now: number): TimerRuntimeState => { + if (state.status !== "running") return state; + return { ...state, status: "paused", remainingMs: getRemainingMs(state, now), deadline: null }; +}; + +export const resetTimerRuntime = (state: TimerRuntimeState, durations: TimerDurations): TimerRuntimeState => { + const phase = getInitialPhase(state.mode); + return { + ...state, + phase, + status: "idle", + remainingMs: getPhaseDurationMs(phase, durations), + totalDurationMs: getPhaseDurationMs(phase, durations), + deadline: null, + completedFocusSessions: 0, + awaitingManualStart: false, + }; +}; + +export const synchronizeTimerRuntime = ( + state: TimerRuntimeState, + mode: TimerMode, + durations: TimerDurations, +): TimerRuntimeState => { + if (state.mode !== mode) { + const initial = createTimerRuntime(mode, durations); + return { ...initial, history: state.history, alerts: state.alerts }; + } + if (state.status !== "idle") return state; + + const remainingMs = getPhaseDurationMs(state.phase, durations); + const completedFocusSessions = Math.min(state.completedFocusSessions, durations.sessionsBeforeLongBreak - 1); + if ( + remainingMs === state.remainingMs && + remainingMs === state.totalDurationMs && + completedFocusSessions === state.completedFocusSessions + ) { + return state; + } + return { ...state, remainingMs, totalDurationMs: remainingMs, completedFocusSessions }; +}; + +export const reconcileTimerRuntime = ( + state: TimerRuntimeState, + now: number, + durations: TimerDurations, +): TimerRuntimeState => { + let next = state; + let reconciled = 0; + + while (next.status === "running" && next.deadline !== null && next.deadline <= now) { + next = completeTimerPhase(next, next.deadline, durations); + reconciled += 1; + if (reconciled >= maximumReconciledPhases) break; + } + + if (reconciled < maximumReconciledPhases) return next; + if (next.status !== "running") return next; + const remainingMs = getPhaseDurationMs(next.phase, durations); + return { ...next, remainingMs, deadline: now + remainingMs }; +}; + +export const setTimerAlertPreference = ( + state: TimerRuntimeState, + preference: keyof TimerAlertPreferences, + enabled: boolean, +): TimerRuntimeState => ({ + ...state, + alerts: { ...state.alerts, [preference]: enabled }, +}); + +const completeTimerPhase = ( + state: TimerRuntimeState, + completedAt: number, + durations: TimerDurations, +): TimerRuntimeState => { + const durationMs = state.totalDurationMs; + const history = [ + ...state.history, + { id: `${state.phase}:${completedAt}`, phase: state.phase, completedAt, durationMs }, + ].slice(-maximumHistoryEntries); + + if (state.mode === "timer") { + return { + ...state, + status: "idle", + remainingMs: getPhaseDurationMs(state.phase, durations), + totalDurationMs: getPhaseDurationMs(state.phase, durations), + deadline: null, + history, + awaitingManualStart: true, + }; + } + + if (state.phase === "focus") { + const completedFocusSessions = state.completedFocusSessions + 1; + const isLongBreak = completedFocusSessions >= durations.sessionsBeforeLongBreak; + const phase: TimerPhase = isLongBreak ? "longBreak" : "shortBreak"; + return startNextPhase( + { ...state, phase, completedFocusSessions, history }, + completedAt, + durations, + durations.autoStartBreaks, + ); + } + + const completedFocusSessions = state.phase === "longBreak" ? 0 : state.completedFocusSessions; + return startNextPhase( + { ...state, phase: "focus", completedFocusSessions, history }, + completedAt, + durations, + durations.autoStartFocus, + ); +}; + +const startNextPhase = ( + state: TimerRuntimeState, + completedAt: number, + durations: TimerDurations, + autoStart: boolean, +): TimerRuntimeState => { + const remainingMs = getPhaseDurationMs(state.phase, durations); + if (!autoStart) { + return { + ...state, + status: "idle", + remainingMs, + totalDurationMs: remainingMs, + deadline: null, + awaitingManualStart: true, + }; + } + return { + ...state, + status: "running", + remainingMs, + totalDurationMs: remainingMs, + deadline: completedAt + remainingMs, + awaitingManualStart: false, + }; +}; + +const getInitialPhase = (mode: TimerMode): TimerPhase => { + if (mode === "timer") return "timer"; + return "focus"; +}; + +const isTimerPhase = (value: unknown): value is TimerPhase => + value === "timer" || value === "focus" || value === "shortBreak" || value === "longBreak"; + +const isTimerStatus = (value: unknown): value is TimerStatus => + value === "idle" || value === "running" || value === "paused"; + +const isTimerHistoryEntry = (value: unknown): value is TimerHistoryEntry => { + if (!isRecord(value) || typeof value.id !== "string" || !isTimerPhase(value.phase)) return false; + return isFiniteNumber(value.completedAt) && isFiniteNumber(value.durationMs) && value.durationMs > 0; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); diff --git a/packages/widgets/src/today/component.tsx b/packages/widgets/src/today/component.tsx new file mode 100644 index 000000000..b98158324 --- /dev/null +++ b/packages/widgets/src/today/component.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { + Badge, + Box, + Group, + Paper, + Progress, + RingProgress, + ScrollArea, + SimpleGrid, + Stack, + Text, + VisuallyHidden, +} from "@mantine/core"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import { formatLocalizedDate } from "../common/locale"; +import { useWidgetNow } from "../common/use-widget-now"; +import type { WidgetComponentProps } from "../definition"; +import { getTodayLayout, getTodayStats } from "./date-stats"; + +const formatProgress = (value: number) => Math.round(value); + +export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) { + const now = useWidgetNow("minute"); + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.today"); + + if (now === null) { + return ( + + — + + ); + } + + const stats = getTodayStats(now, options.weekConvention, locale); + if (displayMode === "advanced") { + return ; + } + + const layout = getTodayLayout(width, height); + const weekday = formatLocalizedDate(now, locale, { weekday: "long" }); + const month = formatLocalizedDate(now, locale, { month: "long" }); + const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" }); + + if (layout.tiny) { + return ( + + + {weekday} · {month} + + + {now.getDate()} + + + ); + } + + return ( + + + + + {weekday} + + + {layout.showFullDate ? fullDate : `${month} ${now.getDate()}`} + + + {options.showYearProgress && ( + + + {formatProgress(stats.yearProgress)}% + + } + /> + + + {t("yearProgress", { progress: formatProgress(stats.yearProgress) })} + + + + )} + + + {layout.showStats && ( + + {options.showWeekNumber && {t("weekNumber", { week: stats.weekNumber })}} + {options.showDayOfYear && ( + {t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })} + )} + {layout.showQuarter && options.showQuarter && ( + {t("quarter", { quarter: stats.quarter })} + )} + {layout.showQuarter && ( + + {t("daysAfterToday", { days: stats.daysAfterToday })} + + )} + + )} + + {layout.showMonthProgress && } + + ); +} + +interface AdvancedTodayProps { + now: Date; + locale: string; + options: WidgetComponentProps<"today">["options"]; + stats: ReturnType; +} + +const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => { + const t = useScopedI18n("widget.today"); + const numberFormatter = new Intl.NumberFormat(locale); + const visibleMetrics = [ + options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) }, + options.showDayOfYear && { + label: t("advanced.dayOfYear"), + value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`, + }, + options.showQuarter && { label: t("advanced.quarter"), value: `Q${stats.quarter}` }, + { label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) }, + ].filter((metric): metric is { label: string; value: string } => Boolean(metric)); + + return ( + + + + + {formatLocalizedDate(now, locale, { dateStyle: "full" })} + + {options.showWeekNumber && ( + + {t(`weekConvention.${options.weekConvention}`)} + + )} + + + + {visibleMetrics.map((metric) => ( + + + {metric.label} + + + {metric.value} + + + ))} + + + + + + {options.showYearProgress && } + + + + {Array.from({ length: 12 }, (_, monthIndex) => { + const date = new Date(now.getFullYear(), monthIndex, 1); + const isCurrent = monthIndex === stats.monthIndex; + return ( + + + {formatLocalizedDate(date, locale, { month: "short" })} + + {isCurrent && ( + + {now.getDate()} + + )} + + ); + })} + + + + ); +}; + +const ProgressMetric = ({ label, value }: { label: string; value: number }) => { + const roundedValue = formatProgress(value); + return ( + + + + {label} + + + {roundedValue}% + + + + + ); +}; + +const toDateTime = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; diff --git a/packages/widgets/src/today/date-stats.spec.ts b/packages/widgets/src/today/date-stats.spec.ts new file mode 100644 index 000000000..414732dfa --- /dev/null +++ b/packages/widgets/src/today/date-stats.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { getTodayLayout, getTodayStats } from "./date-stats"; + +describe("today date statistics", () => { + it("accounts for leap years", () => { + const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso"); + expect(stats.dayOfYear).toBe(366); + expect(stats.daysInYear).toBe(366); + expect(stats.daysAfterToday).toBe(0); + }); + + it("uses the requested week convention", () => { + const date = new Date(2023, 0, 1, 12); + expect(getTodayStats(date, "iso").weekNumber).toBe(52); + expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1); + expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52); + }); + + it("returns bounded calendar progress", () => { + const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso"); + expect(stats.quarter).toBe(3); + expect(stats.weekProgress).toBeGreaterThanOrEqual(0); + expect(stats.weekProgress).toBeLessThanOrEqual(100); + expect(stats.monthProgress).toBeGreaterThanOrEqual(0); + expect(stats.monthProgress).toBeLessThanOrEqual(100); + expect(stats.yearProgress).toBeGreaterThanOrEqual(0); + expect(stats.yearProgress).toBeLessThanOrEqual(100); + }); +}); + +describe("today compact layout", () => { + it("keeps tiny widgets focused on the date", () => { + expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false }); + }); + + it("reveals secondary information only when both axes have room", () => { + expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false }); + expect(getTodayLayout(300, 180)).toEqual({ + tiny: false, + showFullDate: true, + showStats: true, + showQuarter: true, + showMonthProgress: true, + }); + }); +}); diff --git a/packages/widgets/src/today/date-stats.ts b/packages/widgets/src/today/date-stats.ts new file mode 100644 index 000000000..da9313775 --- /dev/null +++ b/packages/widgets/src/today/date-stats.ts @@ -0,0 +1,172 @@ +import dayjs from "dayjs"; +import dayOfYear from "dayjs/plugin/dayOfYear"; +import quarterOfYear from "dayjs/plugin/quarterOfYear"; + +dayjs.extend(dayOfYear); +dayjs.extend(quarterOfYear); + +export type WeekConvention = "locale" | "iso"; + +export interface TodayStats { + dayOfYear: number; + daysInYear: number; + daysAfterToday: number; + monthIndex: number; + quarter: number; + weekNumber: number; + weekProgress: number; + monthProgress: number; + yearProgress: number; +} + +const getProgress = (value: number, start: number, end: number) => { + if (end <= start) return 0; + return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100)); +}; + +export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => { + const value = dayjs(date); + const yearStart = value.startOf("year"); + const nextYearStart = yearStart.add(1, "year"); + const monthStart = value.startOf("month"); + const nextMonthStart = monthStart.add(1, "month"); + const weekRules = getWeekRules(weekConvention, locale); + const weekDay = value.day() === 0 ? 7 : value.day(); + const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7; + const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart); + const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7); + const currentDayOfYear = value.dayOfYear(); + const daysInYear = nextYearStart.diff(yearStart, "day"); + + return { + dayOfYear: currentDayOfYear, + daysInYear, + daysAfterToday: daysInYear - currentDayOfYear, + monthIndex: value.month(), + quarter: value.quarter(), + weekNumber: getWeekNumber(date, weekRules), + weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()), + monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()), + yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()), + }; +}; + +interface WeekRules { + firstDay: number; + minimalDays: number; +} + +interface LocaleWeekInfo { + firstDay: number; + minimalDays?: number; +} + +type LocaleWithWeekInfo = Intl.Locale & { + weekInfo?: LocaleWeekInfo; + getWeekInfo?: () => LocaleWeekInfo; +}; + +const fourDayMinimumRegions = new Set([ + "AD", + "AN", + "AT", + "AX", + "BE", + "BG", + "CH", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FJ", + "FO", + "FR", + "GF", + "GB", + "GG", + "GI", + "GP", + "GR", + "IE", + "IM", + "IS", + "IT", + "LI", + "LT", + "LU", + "MC", + "MQ", + "NL", + "NO", + "PL", + "PT", + "RE", + "RU", + "SE", + "SJ", + "SK", + "VA", +]); + +const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => { + if (convention === "iso") return { firstDay: 1, minimalDays: 4 }; + try { + const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo; + const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.(); + if (weekInfo) { + const region = localeInfo.maximize().region; + const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1; + return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays }; + } + } catch { + // Invalid locales fall back to the common US week convention. + } + return { firstDay: 7, minimalDays: 1 }; +}; + +const getWeekNumber = (date: Date, rules: WeekRules): number => { + const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); + let weekYear = date.getFullYear(); + let firstWeekStart = getFirstWeekStart(weekYear, rules); + + if (dateDay < firstWeekStart) { + weekYear -= 1; + firstWeekStart = getFirstWeekStart(weekYear, rules); + } else { + const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules); + if (dateDay >= nextFirstWeekStart) return 1; + } + + return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1; +}; + +const getFirstWeekStart = (year: number, rules: WeekRules): number => { + const januaryFirst = Date.UTC(year, 0, 1); + const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay(); + const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay; + const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7; + const daysInFirstWeek = 7 - daysBeforeJanuary; + if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000; + return januaryFirst + daysInFirstWeek * 86_400_000; +}; + +export interface TodayLayout { + tiny: boolean; + showFullDate: boolean; + showStats: boolean; + showQuarter: boolean; + showMonthProgress: boolean; +} + +export const getTodayLayout = (width: number, height: number): TodayLayout => { + const tiny = width < 150 || height < 96; + return { + tiny, + showFullDate: !tiny && width >= 180, + showStats: !tiny && width >= 210 && height >= 115, + showQuarter: width >= 240 && height >= 145, + showMonthProgress: width >= 300 && height >= 170, + }; +}; diff --git a/packages/widgets/src/today/index.ts b/packages/widgets/src/today/index.ts new file mode 100644 index 000000000..e494df35b --- /dev/null +++ b/packages/widgets/src/today/index.ts @@ -0,0 +1,24 @@ +import { IconCalendarWeek } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("today", { + icon: IconCalendarWeek, + supportsAdvancedFocus: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + weekConvention: factory.select({ + defaultValue: "locale", + options: [ + { value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") }, + { value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") }, + ], + }), + showWeekNumber: factory.switch({ defaultValue: true }), + showDayOfYear: factory.switch({ defaultValue: true }), + showQuarter: factory.switch({ defaultValue: true }), + showYearProgress: factory.switch({ defaultValue: true }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/weather/advanced.tsx b/packages/widgets/src/weather/advanced.tsx new file mode 100644 index 000000000..d60eea81a --- /dev/null +++ b/packages/widgets/src/weather/advanced.tsx @@ -0,0 +1,439 @@ +import type { ReactNode } from "react"; +import { useId } from "react"; +import { CompositeChart } from "@mantine/charts"; +import { + Box, + Group, + Paper, + Popover, + ScrollArea, + SimpleGrid, + Stack, + Text, + UnstyledButton, + VisuallyHidden, +} from "@mantine/core"; +import { IconDroplets, IconMapPin, IconSunHigh, IconSunrise, IconSunset, IconWind } from "@tabler/icons-react"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetProps } from "../definition"; +import { AnimatedWeatherIcon } from "./animated-icon"; +import classes from "./component.module.css"; +import { DailyWeatherDetails } from "./details"; +import type { HourlyChartRow, WeatherData } from "./format"; +import { + getHourlyChartData, + getPreferredDate, + getPreferredTime, + getPreferredUnit, + getPreferredWindSpeed, +} from "./format"; +import { getWeatherKind, WeatherDescription } from "./icon"; +import { getAdvancedWeatherLayout } from "./layout"; + +interface AdvancedWeatherProps { + height: number; + options: WidgetProps<"weather">["options"]; + weather: WeatherData; + width: number; +} + +interface MetricCardProps { + icon: ReactNode; + label: string; + value: ReactNode; +} + +interface WeatherChartDatum { + detailLabel: string; + observedAt: string; + precipitationProbability: number; + precipitationSummary: string; + temperature: number; + temperatureSummary: string; + tooltipLabel: string; +} + +interface HourlyChartTooltipProps { + active?: boolean; + payload?: Array<{ payload?: WeatherChartDatum }>; +} + +const HourlyChartTooltip = ({ active, payload }: HourlyChartTooltipProps) => { + const datum = payload?.[0]?.payload; + if (!active || !datum) return null; + + return ( + + + {datum.tooltipLabel} + + + {datum.temperatureSummary} · {datum.precipitationSummary} + + {datum.detailLabel} + + ); +}; + +const MetricCard = ({ icon, label, value }: MetricCardProps) => ( + + + {icon} + + + {label} + + + {value} + + + + +); + +export const AdvancedWeather = ({ height, options, weather, width }: AdvancedWeatherProps) => { + const headingId = useId(); + const chartId = useId(); + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.weather"); + const tCommon = useScopedI18n("common"); + const layout = getAdvancedWeatherLayout(width, height); + const today = weather.daily[0]; + const nextHour = weather.hourly[0]; + const hourly = getHourlyChartData(weather, options.isFormatFahrenheit); + const speedUnit = options.useImperialSpeed + ? tCommon("unit.speed.milesPerHour") + : tCommon("unit.speed.kilometersPerHour"); + const temperatureUnit = options.isFormatFahrenheit ? "°F" : "°C"; + const chartData = hourly.map((hour) => { + const condition = hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`); + const temperature = hour.temperature === null ? "?" : hour.temperature.toFixed(1); + const precipitationProbability = hour.precipitationProbability ?? "?"; + let detailLabel = t("advanced.hourlyTooltipWithoutHumidity", { + amount: hour.precipitation?.toFixed(1) ?? "?", + feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?", + speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed), + speedUnit, + temperatureUnit, + }); + if (options.showHumidity) { + detailLabel = t("advanced.hourlyTooltip", { + amount: hour.precipitation?.toFixed(1) ?? "?", + feelsLike: hour.apparentTemperature?.toFixed(1) ?? "?", + humidity: hour.relativeHumidity ?? "?", + speed: getPreferredWindSpeed(hour.windSpeed, options.useImperialSpeed), + speedUnit, + temperatureUnit, + }); + } + + return { + detailLabel, + observedAt: hour.observedAt, + precipitationProbability: hour.precipitationProbability ?? Number.NaN, + precipitationSummary: `${t("advanced.precipitationChance")}: ${precipitationProbability}%`, + temperature: hour.temperature ?? Number.NaN, + temperatureSummary: `${t("advanced.temperature")}: ${temperature}${temperatureUnit}`, + tooltipLabel: `${getPreferredTime(hour.observedAt, locale, weather.timezone)} · ${condition}`, + }; + }); + const ticks = hourly.filter((_, index) => index % layout.hourlyTickStep === 0).map((hour) => hour.observedAt); + + return ( + + + + + + + + {getPreferredUnit( + weather.current.temperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + + {t("advanced.feelsLike", { + temperature: getPreferredUnit( + weather.current.apparentTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + ), + })} + + + + + + + + {options.location.name} + + + + {t("advanced.observedAt", { + time: getPreferredTime(weather.current.observedAt, locale, weather.timezone), + })} + + {today && ( + + {t("advanced.highLow", { + maximum: getPreferredUnit( + today.maxTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + ), + minimum: getPreferredUnit( + today.minTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + ), + })} + + )} + + + + + } + label={t("advanced.nextHourPrecipitation")} + value={t("advanced.precipitationValue", { + amount: nextHour?.precipitation?.toFixed(1) ?? "?", + probability: nextHour?.precipitationProbability ?? "?", + })} + /> + {options.showHumidity && ( + } + label={t("advanced.currentHumidity")} + value={`${weather.current.relativeHumidity}%`} + /> + )} + } + label={t("advanced.currentWind")} + value={t("advanced.windValue", { + gusts: getPreferredWindSpeed(weather.current.windGusts, options.useImperialSpeed), + speed: getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed), + unit: speedUnit, + })} + /> + } + label={t("advanced.todayUv")} + value={today?.uvIndex?.toFixed(1) ?? "?"} + /> + } + label={t("dailyForecast.sunrise")} + value={getPreferredTime(today?.sunriseAt, locale, weather.timezone)} + /> + } + label={t("dailyForecast.sunset")} + value={getPreferredTime(today?.sunsetAt, locale, weather.timezone)} + /> + + + {chartData.length > 0 && ( + + + {t("advanced.hourlyForecast")} + + + `${value}°` }} + rightYAxisProps={{ width: 38, domain: [0, 100], tickFormatter: (value: number) => `${value}%` }} + xAxisProps={{ + ticks, + interval: 0, + tickFormatter: (value: string) => getPreferredTime(value, locale, weather.timezone), + }} + tooltipAnimationDuration={0} + tooltipProps={{ content: }} + /> + + + + + + )} + + + {t("advanced.forecast")} + + + {weather.daily.map((day) => ( + + + + + + + + + {getPreferredDate(day.date, locale, { weekday: "short" })} + + + {getPreferredDate(day.date, locale, { month: "short", day: "numeric" })} + + + + + + + {t(`kind.${getWeatherKind(day.weatherCode)}`)} + + + + + {getPreferredUnit( + day.maxTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + {getPreferredUnit( + day.minTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + + + + + + {day.precipitationProbability ?? "?"}% + + + + + + {getPreferredWindSpeed(day.maxWindSpeed, options.useImperialSpeed)} {speedUnit} + + + + + + + + + + + + ))} + + + + ); +}; + +interface HourlyDetailProps { + locale: string; + showHumidity: boolean; + speedUnit: string; + temperatureUnit: string; + timeZone: string; + useImperialSpeed: boolean; +} + +const HourlyWeatherTable = ({ + hourly, + locale, + showHumidity, + speedUnit, + temperatureUnit, + timeZone, + useImperialSpeed, +}: HourlyDetailProps & { hourly: HourlyChartRow[] }) => { + const t = useScopedI18n("widget.weather"); + return ( + + + + + + + + + + {showHumidity && } + + + + + {hourly.map((hour) => ( + + + + + + + {showHumidity && } + + + ))} + +
{t("advanced.hourlyForecast")}
{t("advanced.time")}{t("advanced.conditions")}{t("advanced.temperature")}{t("advanced.feelsLikeLabel")}{t("advanced.precipitationChance")}{t("advanced.humidity")}{t("advanced.wind", { unit: speedUnit })}
{getPreferredTime(hour.observedAt, locale, timeZone)}{hour.weatherCode === null ? t("kind.unknown") : t(`kind.${getWeatherKind(hour.weatherCode)}`)}{hour.temperature === null ? "?" : `${hour.temperature.toFixed(1)}${temperatureUnit}`} + {hour.apparentTemperature === null ? "?" : `${hour.apparentTemperature.toFixed(1)}${temperatureUnit}`} + {hour.precipitationProbability === null ? "?" : `${hour.precipitationProbability}%`}{hour.relativeHumidity === null ? "?" : `${hour.relativeHumidity}%`} + {getPreferredWindSpeed(hour.windSpeed, useImperialSpeed)} /{" "} + {getPreferredWindSpeed(hour.windGusts, useImperialSpeed)} {speedUnit} +
+ ); +}; diff --git a/packages/widgets/src/weather/animated-icon.tsx b/packages/widgets/src/weather/animated-icon.tsx index e95fa2f47..cbe2e0949 100644 --- a/packages/widgets/src/weather/animated-icon.tsx +++ b/packages/widgets/src/weather/animated-icon.tsx @@ -3,7 +3,9 @@ import "../widgets-common.css"; import { WeatherIcon } from "./icon"; interface AnimatedWeatherIconProps { + animated?: boolean; code: number; + isDay?: boolean; size?: string | number; } @@ -17,10 +19,11 @@ const getAnimationClass = (code: number): string => { return ""; }; -export const AnimatedWeatherIcon = ({ code, size = 26 }: AnimatedWeatherIconProps) => { +export const AnimatedWeatherIcon = ({ animated = false, code, isDay, size = 26 }: AnimatedWeatherIconProps) => { + const animationClass = animated ? getAnimationClass(code) : ""; return ( - - + + ); }; diff --git a/packages/widgets/src/weather/compact.tsx b/packages/widgets/src/weather/compact.tsx new file mode 100644 index 000000000..f2cb7e5ad --- /dev/null +++ b/packages/widgets/src/weather/compact.tsx @@ -0,0 +1,169 @@ +import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core"; +import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetProps } from "../definition"; +import { AnimatedWeatherIcon } from "./animated-icon"; +import classes from "./component.module.css"; +import { DailyWeatherDetails } from "./details"; +import type { WeatherData } from "./format"; +import { getPreferredDate, getPreferredUnit, getPreferredWindSpeed } from "./format"; +import { formatWeatherDate, WeatherDescription } from "./icon"; +import { getCompactWeatherLayout } from "./layout"; + +interface CompactWeatherProps { + height: number; + isEditMode: boolean; + options: WidgetProps<"weather">["options"]; + weather: WeatherData; + width: number; +} + +export const CompactWeather = ({ height, isEditMode, options, weather, width }: CompactWeatherProps) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.weather"); + const tCommon = useScopedI18n("common"); + const layout = getCompactWeatherLayout(width, height, options.hasForecast, options.forecastDayCount); + const today = weather.daily[0]; + const speedUnit = options.useImperialSpeed + ? tCommon("unit.speed.milesPerHour") + : tCommon("unit.speed.kilometersPerHour"); + const contentGap = layout.tier === "micro" ? 2 : 4; + const contentPadding = layout.tier === "roomy" ? "xs" : 4; + + return ( + + + + + + + + + + + + + + + {getPreferredUnit( + weather.current.temperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + {layout.showCondition && } + + + + {layout.showHighLow && today && ( + + + + + {getPreferredUnit(today.maxTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)} + + + + + + {getPreferredUnit(today.minTemperature, options.isFormatFahrenheit, options.disableTemperatureDecimals)} + + + + )} + + {layout.showSecondary && !options.hasForecast && ( + + {options.showHumidity && ( + + + {weather.current.relativeHumidity}% + + )} + {options.showCurrentWindSpeed && ( + + + + {getPreferredWindSpeed(weather.current.windSpeed, options.useImperialSpeed)} {speedUnit} + + + )} + + )} + + {layout.forecastDays > 0 && ( + + {weather.daily.slice(0, layout.forecastDays).map((day, index) => ( + + + + + + {getPreferredDate(day.date, locale, { weekday: "short" })} + + + + {getPreferredUnit( + day.maxTemperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + + + + + + + ))} + + )} + + {layout.showCity && options.showCity && ( + + + + {options.location.name} + + + )} + + ); +}; diff --git a/packages/widgets/src/weather/component.module.css b/packages/widgets/src/weather/component.module.css index 4b7b623be..df523cd81 100644 --- a/packages/widgets/src/weather/component.module.css +++ b/packages/widgets/src/weather/component.module.css @@ -1,5 +1,85 @@ +.weatherSurface { + overflow: hidden; +} + +.weatherContent { + min-width: 0; + min-height: 0; +} + +.compactRoot { + overflow: hidden; +} + +.compactRoot[data-tier="micro"] { + flex-direction: row; +} + +.compactMetrics { + min-width: 0; +} + +.advancedRoot { + min-width: 0; +} + +.locationSummary { + max-width: min(100%, 320px); +} + +.metricCard, +.dailyCard, +.detailMetric, +.solarDetails { + background: color-mix(in srgb, var(--mantine-color-body) 72%, transparent); + border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); +} + +.dailyCard { + transition: + background-color 120ms ease, + border-color 120ms ease; +} + +.dailyCardButton:hover .dailyCard, +.dailyCardButton:focus-visible .dailyCard { + background: color-mix(in srgb, var(--mantine-primary-color-light) 30%, var(--mantine-color-body)); + border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 45%, var(--mantine-color-default-border)); +} + +.dailyDetailsDropdown { + width: min(390px, calc(100vw - 32px)); + padding: var(--mantine-spacing-sm); +} + +.dailyDetails { + min-width: 0; +} + +.dailyDetailsTemperature { + flex-shrink: 0; + max-width: 132px; +} + +.dailyCardFacts { + min-width: 0; +} + +.dailyCardFact { + white-space: nowrap; +} + +.detailMetric { + min-width: 0; +} + +.solarDetails { + background: color-mix(in srgb, var(--mantine-color-yellow-light) 24%, var(--mantine-color-body)); +} + .detailsButton, -.forecastButton { +.forecastButton, +.dailyCardButton { color: inherit; border-radius: var(--mantine-radius-sm); } @@ -12,20 +92,48 @@ min-height: 24px; } -.forecastButton { +.forecastButton, +.dailyCardButton { display: block; } +.dailyCardButton, +.dailyCard { + width: 100%; + height: 100%; +} + .detailsButton:focus-visible, -.forecastButton:focus-visible { +.forecastButton:focus-visible, +.dailyCardButton:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 2px; } +.errorIndicator { + z-index: 2; +} + @media (any-pointer: coarse) { .detailsButton, - .forecastButton { + .forecastButton, + .dailyCardButton { min-width: 44px; min-height: 44px; } } + +@media (prefers-reduced-motion: reduce) { + .dailyCard { + transition: none; + } +} + +@media (forced-colors: active) { + .metricCard, + .dailyCard, + .detailMetric, + .solarDetails { + border: 1px solid CanvasText; + } +} diff --git a/packages/widgets/src/weather/component.spec.ts b/packages/widgets/src/weather/component.spec.ts index fc0d219a2..b60dbe4fb 100644 --- a/packages/widgets/src/weather/component.spec.ts +++ b/packages/widgets/src/weather/component.spec.ts @@ -1,26 +1,25 @@ import { describe, expect, it } from "vitest"; -import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component"; +import { reduceWidgetOptionsWithDefinition } from "../manifest"; +import { definition } from "."; +import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format"; import { formatWeatherDate } from "./icon"; +import { getAdvancedWeatherLayout, getCompactWeatherLayout } from "./layout"; describe("weather formatting", () => { - it("preserves valid zero temperatures", () => { + it("preserves valid zero values", () => { expect(getPreferredUnit(0)).toBe("0.0°C"); expect(getPreferredUnit(0, true, true)).toBe("32°F"); + expect(getPreferredWindSpeed(0)).toBe("0.0"); }); - it("keeps zero wind speeds and missing forecast values truthful", () => { - expect(getPreferredWindSpeed(0)).toBe("0.0"); + it("keeps missing and invalid values truthful", () => { + expect(getPreferredUnit(null)).toBe("?"); expect(getPreferredWindSpeed(undefined)).toBe("?"); expect(getPreferredTime(undefined, "en-US")).toBe("?"); expect(getPreferredTime("invalid", "en-US")).toBe("?"); }); - it("limits compact forecasts by both axes", () => { - expect(getVisibleForecastDays(7, 120, 100)).toBe(2); - expect(getVisibleForecastDays(7, 600, 100)).toBe(3); - }); - it.each([ ["dddd, MMMM D", "Monday, August 3"], ["dddd, D MMMM", "Monday, 3 August"], @@ -34,3 +33,61 @@ describe("weather formatting", () => { expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected); }); }); + +describe("weather layout", () => { + it("keeps micro widgets focused on current temperature", () => { + expect(getCompactWeatherLayout(120, 100, true, 7)).toEqual({ + tier: "micro", + forecastDays: 0, + showCity: false, + showCondition: false, + showHighLow: false, + showSecondary: false, + }); + }); + + it("caps compact forecasts by size and saved preference", () => { + expect(getCompactWeatherLayout(200, 200, true, 7).forecastDays).toBe(2); + expect(getCompactWeatherLayout(424, 200, true, 3).forecastDays).toBe(3); + expect(getCompactWeatherLayout(640, 360, true, 7).forecastDays).toBe(7); + expect(getCompactWeatherLayout(640, 360, false, 7).forecastDays).toBe(0); + }); + + it("adapts the advanced view to mobile and desktop surfaces", () => { + expect(getAdvancedWeatherLayout(374, 624)).toMatchObject({ + dailyColumns: 2, + hourlyTickStep: 6, + showChartAxes: false, + }); + expect(getAdvancedWeatherLayout(800, 560)).toMatchObject({ + chartHeight: 200, + dailyColumns: 4, + hourlyTickStep: 3, + showChartAxes: true, + }); + }); +}); + +describe("weather defaults", () => { + it("starts rich and static while preserving saved false values", () => { + const options = definition.createOptions(); + expect(options.showCity.defaultValue).toBe(true); + expect(options.hasForecast.defaultValue).toBe(true); + expect(options.forecastDayCount.defaultValue).toBe(3); + expect(options.animateIcons.defaultValue).toBe(false); + expect(options.showHumidity.defaultValue).toBe(true); + + const reduced = reduceWidgetOptionsWithDefinition(definition, {} as never, { + showCity: false, + hasForecast: false, + showHumidity: false, + isFormatFahrenheit: true, + }); + expect(reduced).toMatchObject({ + showCity: false, + hasForecast: false, + showHumidity: false, + isFormatFahrenheit: true, + }); + }); +}); diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 357318fa6..f30077865 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,32 +1,20 @@ "use client"; -import { useId } from "react"; -import { Box, Group, Popover, Stack, Table, Text, UnstyledButton } from "@mantine/core"; -import { - IconArrowDownRight, - IconArrowUpRight, - IconDroplets, - IconMapPin, - IconSunrise, - IconSunset, - IconWind, -} from "@tabler/icons-react"; -import combineClasses from "clsx"; -import dayjs from "dayjs"; +import { Box } from "@mantine/core"; -import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { metricToImperial } from "@homarr/common"; -import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { formatLocalizedDate, formatLocalizedTime } from "../common/locale"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; -import { AnimatedWeatherIcon } from "./animated-icon"; -import { formatWeatherDate, WeatherDescription } from "./icon"; +import { AdvancedWeather } from "./advanced"; import classes from "./component.module.css"; +import { CompactWeather } from "./compact"; + +export { getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format"; +export { getCompactWeatherLayout } from "./layout"; export default function WeatherWidget({ isEditMode, @@ -46,419 +34,20 @@ export default function WeatherWidget({ if (isInitialWidgetQueryPending(weatherQuery)) return ; if (!weather) return ; - const content = - displayMode === "advanced" ? ( - - ) : ( - - {options.hasForecast ? ( - - ) : ( - - )} - - ); - return ( - - {content} + + + {displayMode === "advanced" ? ( + + ) : ( + + )} + {weatherQuery.error && ( - + )} ); } - -interface WeatherProps extends Pick, "options"> { - weather: NonNullable; -} - -interface CompactWeatherProps extends WeatherProps { - isEditMode: boolean; -} - -const AdvancedWeather = ({ options, weather }: WeatherProps) => { - const forecastHeadingId = useId(); - const locale = useCurrentIntlLocale(); - const t = useScopedI18n("widget.weather"); - const tCommon = useScopedI18n("common"); - - return ( - - - - - - - {getPreferredUnit( - weather.current.temperature, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - - - - - - {options.location.name} - - - - - {t("currentWindSpeed", { - currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed), - unit: options.useImperialSpeed - ? tCommon("unit.speed.milesPerHour") - : tCommon("unit.speed.kilometersPerHour"), - })} - - - - - - - {t("advanced.forecast")} - - - - - - {t("advanced.day")} - {t("advanced.conditions")} - {t("advanced.temperature")} - {t("advanced.humidity")} - - {t("advanced.wind", { - unit: options.useImperialSpeed - ? tCommon("unit.speed.milesPerHour") - : tCommon("unit.speed.kilometersPerHour"), - })} - - {t("advanced.sun")} - - - - {weather.daily.length === 0 ? ( - - - - {t("advanced.noForecast")} - - - - ) : ( - weather.daily.map((dayWeather) => ( - - - - - {formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })} - - - {formatLocalizedDate(dayWeather.time, locale, { month: "short", day: "numeric" })} - - - - - - - - - - - - - - {getPreferredUnit( - dayWeather.maxTemp, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - / - - - - {getPreferredUnit( - dayWeather.minTemp, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - - - - {dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`} - - - - - - - {getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "} - {getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)} - - - - - - - - {getPreferredTime(dayWeather.sunrise, locale)} - - - / - - - - {getPreferredTime(dayWeather.sunset, locale)} - - - - - )) - )} - -
-
-
- ); -}; - -const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) => { - const t = useScopedI18n("widget.weather"); - const tCommon = useScopedI18n("common"); - - return ( - <> - - - - - - - - - - - - - {getPreferredUnit( - weather.current.temperature, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - {options.showCurrentWindSpeed && ( - - - - {t("currentWindSpeed", { - currentWindSpeed: (options.useImperialSpeed - ? metricToImperial(weather.current.windspeed) - : weather.current.windspeed - ).toFixed(1), - unit: options.useImperialSpeed - ? tCommon("unit.speed.milesPerHour") - : tCommon("unit.speed.kilometersPerHour"), - })} - - - )} - {weather.daily[0]?.humidity !== undefined && ( - - - {t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })} - - )} - - - - - {getPreferredUnit( - weather.daily[0]?.maxTemp, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - - - {getPreferredUnit( - weather.daily[0]?.minTemp, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - - {options.showCity && ( - <> - - - - {options.location.name} - - - - )} - - ); -}; - -const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) => { - const t = useScopedI18n("widget.weather"); - - return ( - <> - - {options.showCity && ( - - - - {options.location.name} - - - )} - - - - - - - - - - - - - {getPreferredUnit( - weather.current.temperature, - options.isFormatFahrenheit, - options.disableTemperatureDecimals, - )} - - - - - - ); -}; - -function Forecast({ weather, options, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) { - const dateFormat = options.dateFormat; - const t = useScopedI18n("widget.weather"); - const locale = useCurrentIntlLocale(); - return ( - - {weather.daily.slice(0, maxDays).map((dayWeather, index) => ( - - - - - - {formatLocalizedDate(dayWeather.time, locale, { weekday: "short" })} - - - - {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - - - - - - - - - ))} - - ); -} - -export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string => - value !== undefined - ? isFahrenheit - ? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F` - : `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C` - : "?"; - -export const getPreferredWindSpeed = (value: number | undefined, useImperialSpeed = false): string => - value === undefined ? "?" : (useImperialSpeed ? metricToImperial(value) : value).toFixed(1); - -export const getPreferredTime = (value: string | undefined, locale = "en-US"): string => - value ? formatLocalizedTime(value, locale) : "?"; - -export const getVisibleForecastDays = (configuredDays: number, width: number, height: number): number => { - const widthBudget = Math.max(1, Math.floor(width / 58)); - const heightBudget = height < 120 ? 3 : configuredDays; - return Math.min(configuredDays, widthBudget, heightBudget); -}; diff --git a/packages/widgets/src/weather/details.tsx b/packages/widgets/src/weather/details.tsx new file mode 100644 index 000000000..6316de5dd --- /dev/null +++ b/packages/widgets/src/weather/details.tsx @@ -0,0 +1,161 @@ +import type { ReactNode } from "react"; +import { Group, Paper, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + IconDroplets, + IconGauge, + IconSunHigh, + IconSunrise, + IconSunset, + IconTemperature, + IconWind, +} from "@tabler/icons-react"; + +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetProps } from "../definition"; +import { AnimatedWeatherIcon } from "./animated-icon"; +import classes from "./component.module.css"; +import type { DailyWeatherData } from "./format"; +import { getPreferredDaylightDuration, getPreferredTime, getPreferredUnit, getPreferredWindSpeed } from "./format"; +import { formatWeatherDate, getWeatherKind } from "./icon"; + +interface DailyWeatherDetailsProps { + animateIcons: boolean; + day: DailyWeatherData; + disableTemperatureDecimals: boolean; + isFahrenheit: boolean; + dateFormat: WidgetProps<"weather">["options"]["dateFormat"]; + showHumidity: boolean; + timeZone: string; + useImperialSpeed: boolean; +} + +interface DetailMetricProps { + icon: ReactNode; + label: string; + value: ReactNode; +} + +const DetailMetric = ({ icon, label, value }: DetailMetricProps) => ( + + + + {icon} + + + + {label} + + + {value} + + + + +); + +const SolarMetric = ({ icon, label, value }: DetailMetricProps) => ( + + + {icon} + + + {label} + + + {value} + + +); + +export const DailyWeatherDetails = ({ + animateIcons, + day, + dateFormat, + disableTemperatureDecimals, + isFahrenheit, + showHumidity, + timeZone, + useImperialSpeed, +}: DailyWeatherDetailsProps) => { + const locale = useCurrentIntlLocale(); + const t = useScopedI18n("widget.weather"); + const tCommon = useScopedI18n("common"); + const speedUnit = useImperialSpeed ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour"); + + return ( + + + + + + + + + {formatWeatherDate(day.date, locale, dateFormat)} + + + {t(`kind.${getWeatherKind(day.weatherCode)}`)} + + + + + {t("advanced.highLow", { + maximum: getPreferredUnit(day.maxTemperature, isFahrenheit, disableTemperatureDecimals), + minimum: getPreferredUnit(day.minTemperature, isFahrenheit, disableTemperatureDecimals), + })} + + + + + } + label={t("advanced.detailLabel.feelsLike")} + value={`${getPreferredUnit(day.maxApparentTemperature, isFahrenheit, disableTemperatureDecimals)} / ${getPreferredUnit(day.minApparentTemperature, isFahrenheit, disableTemperatureDecimals)}`} + /> + } + label={t("advanced.detailLabel.precipitation")} + value={`${day.precipitationProbability ?? "?"}% · ${day.precipitation?.toFixed(1) ?? "?"} mm`} + /> + {showHumidity && ( + } + label={t("advanced.detailLabel.humidity")} + value={`${day.averageHumidity ?? "?"}%`} + /> + )} + } + label={t("advanced.detailLabel.wind")} + value={`${getPreferredWindSpeed(day.maxWindSpeed, useImperialSpeed)} / ${getPreferredWindSpeed(day.maxWindGusts, useImperialSpeed)} ${speedUnit}`} + /> + } + label={t("advanced.detailLabel.uv")} + value={day.uvIndex?.toFixed(1) ?? "?"} + /> + + + + + } + label={t("dailyForecast.sunrise")} + value={getPreferredTime(day.sunriseAt, locale, timeZone)} + /> + } + label={t("dailyForecast.sunset")} + value={getPreferredTime(day.sunsetAt, locale, timeZone)} + /> + } + label={t("advanced.detailLabel.daylight")} + value={getPreferredDaylightDuration(day.daylightDuration)} + /> + + + + ); +}; diff --git a/packages/widgets/src/weather/format.ts b/packages/widgets/src/weather/format.ts new file mode 100644 index 000000000..a65c213ce --- /dev/null +++ b/packages/widgets/src/weather/format.ts @@ -0,0 +1,60 @@ +import type { RouterOutputs } from "@homarr/api"; +import { formatDuration, metricToImperial } from "@homarr/common"; + +import { formatLocalizedDate, formatLocalizedTime } from "../common/locale"; + +export type WeatherData = NonNullable; +export type DailyWeatherData = WeatherData["daily"][number]; + +export const toPreferredTemperature = (value: number | null | undefined, isFahrenheit = false): number | null => { + if (value === null || value === undefined) return null; + if (isFahrenheit) return value * (9 / 5) + 32; + return value; +}; + +export const getPreferredUnit = ( + value: number | null | undefined, + isFahrenheit = false, + disableTemperatureDecimals = false, +): string => { + const preferred = toPreferredTemperature(value, isFahrenheit); + if (preferred === null) return "?"; + return `${preferred.toFixed(disableTemperatureDecimals ? 0 : 1)}°${isFahrenheit ? "F" : "C"}`; +}; + +export const getPreferredWindSpeed = (value: number | null | undefined, useImperialSpeed = false): string => { + if (value === null || value === undefined) return "?"; + return (useImperialSpeed ? metricToImperial(value) : value).toFixed(1); +}; + +export const getPreferredTime = (value: string | null | undefined, locale = "en-US", timeZone?: string): string => { + if (!value) return "?"; + return formatLocalizedTime(value, locale, { timeZone }); +}; + +export const getPreferredDate = (value: string, locale: string, options: Intl.DateTimeFormatOptions): string => + formatLocalizedDate(value, locale, options); + +export const getPreferredDaylightDuration = (value: number | null | undefined): string => { + if (value === null || value === undefined) return "?"; + return formatDuration(value * 1000); +}; + +export interface HourlyChartRow { + observedAt: string; + temperature: number | null; + apparentTemperature: number | null; + precipitationProbability: number | null; + precipitation: number | null; + weatherCode: number | null; + relativeHumidity: number | null; + windSpeed: number | null; + windGusts: number | null; +} + +export const getHourlyChartData = (weather: WeatherData, isFahrenheit: boolean): HourlyChartRow[] => + weather.hourly.map((hour) => ({ + ...hour, + temperature: toPreferredTemperature(hour.temperature, isFahrenheit), + apparentTemperature: toPreferredTemperature(hour.apparentTemperature, isFahrenheit), + })); diff --git a/packages/widgets/src/weather/icon.tsx b/packages/widgets/src/weather/icon.tsx index 50268b1c1..3eeb3696b 100644 --- a/packages/widgets/src/weather/icon.tsx +++ b/packages/widgets/src/weather/icon.tsx @@ -23,6 +23,7 @@ import type { WidgetProps } from "../definition"; interface WeatherIconProps { code: number; + isDay?: boolean; size?: string | number; } @@ -32,12 +33,16 @@ interface WeatherIconProps { * @param size size of the icon, accepts relative sizes too * @returns Icon corresponding to the weather code */ -export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => { +export const WeatherIcon = ({ code, isDay = true, size = 50 }: WeatherIconProps) => { + if (code === 0 && !isDay) return ; const { icon: Icon } = weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather; return ; }; +export const getWeatherKind = (code: number) => + (weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? unknownWeather).name; + interface WeatherDescriptionProps { weatherOnly?: boolean; useImperialSpeed?: boolean; @@ -84,7 +89,7 @@ export const WeatherDescription = ({ const tCommon = useScopedI18n("common"); const locale = useCurrentIntlLocale(); - const { name } = weatherDefinitions.find((definition) => definition.codes.includes(weatherCode)) ?? unknownWeather; + const name = getWeatherKind(weatherCode); if (weatherOnly) { return {t(`kind.${name}`)}; @@ -95,12 +100,22 @@ export const WeatherDescription = ({ {formatWeatherDate(time, locale, dateFormat)} {t(`kind.${name}`)} - }>{`${tCommon("information.max")}: ${maxTemp}`} - }>{`${tCommon("information.min")}: ${minTemp}`} - }>{`${t("dailyForecast.sunrise")}: ${sunrise}`} - }>{`${t("dailyForecast.sunset")}: ${sunset}`} + } + >{`${tCommon("information.max")}: ${maxTemp}`} + } + >{`${tCommon("information.min")}: ${minTemp}`} + } + >{`${t("dailyForecast.sunrise")}: ${sunrise}`} + } + >{`${t("dailyForecast.sunset")}: ${sunset}`} {humidity !== undefined && ( - }>{t("dailyForecast.humidity", { humidity })} + }> + {t("dailyForecast.humidity", { humidity })} + )} {maxWindSpeed !== undefined && ( }> diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 11ced3b39..73a7cf40a 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("weather", icon: IconCloud, supportsAdvancedFocus: true, queryKey: [["widget", "weather", "atLocation"]], + refetchInterval: 600, queryMatcher: ({ input }, scope) => { const location = scope.options.location; if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) { @@ -24,6 +25,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather", (factory) => ({ isFormatFahrenheit: factory.switch(), disableTemperatureDecimals: factory.switch(), + animateIcons: factory.switch(), + showHumidity: factory.switch({ defaultValue: true, withDescription: true }), showCurrentWindSpeed: factory.switch({ withDescription: true }), useImperialSpeed: factory.switch(), location: factory.location({ @@ -47,10 +50,10 @@ export const { definition, componentLoader } = createWidgetDefinition("weather", defaultValue: "dddd, MMMM D", withDescription: true, }), - showCity: factory.switch(), - hasForecast: factory.switch(), + showCity: factory.switch({ defaultValue: true }), + hasForecast: factory.switch({ defaultValue: true }), forecastDayCount: factory.slider({ - defaultValue: 5, + defaultValue: 3, validate: z.number().min(1).max(7), step: 1, withDescription: true, diff --git a/packages/widgets/src/weather/layout.ts b/packages/widgets/src/weather/layout.ts new file mode 100644 index 000000000..bf647b7af --- /dev/null +++ b/packages/widgets/src/weather/layout.ts @@ -0,0 +1,58 @@ +export type CompactWeatherTier = "micro" | "compact" | "standard" | "roomy"; + +export interface CompactWeatherLayout { + tier: CompactWeatherTier; + forecastDays: number; + showCity: boolean; + showCondition: boolean; + showHighLow: boolean; + showSecondary: boolean; +} + +export const getCompactWeatherLayout = ( + width: number, + height: number, + hasForecast: boolean, + configuredDays: number, +): CompactWeatherLayout => { + if (height < 120 || width < 160) { + return { + tier: "micro", + forecastDays: 0, + showCity: false, + showCondition: false, + showHighLow: false, + showSecondary: false, + }; + } + + if (height < 180 || width < 280) { + return { + tier: "compact", + forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(width / 92))) : 0, + showCity: width >= 220, + showCondition: true, + showHighLow: true, + showSecondary: false, + }; + } + + const roomy = width >= 500 && height >= 280; + const availableForecastWidth = Math.max(0, width - 24); + return { + tier: roomy ? "roomy" : "standard", + forecastDays: hasForecast ? Math.min(configuredDays, Math.max(1, Math.floor(availableForecastWidth / 64))) : 0, + showCity: true, + showCondition: true, + showHighLow: true, + showSecondary: true, + }; +}; + +export const getAdvancedWeatherLayout = (width: number, height: number) => ({ + chartHeight: height >= 520 ? 200 : 150, + dailyColumns: width >= 700 ? 4 : width >= 480 ? 3 : width >= 320 ? 2 : 1, + hourlyTickStep: width >= 520 ? 3 : 6, + metricColumns: width >= 700 ? 3 : width >= 420 ? 2 : 1, + showChartAxes: width >= 520, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a17aa075..1cc190710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2910,6 +2910,9 @@ importers: '@mantine/hooks': specifier: 'catalog:' version: 9.4.1(react@19.2.7) + '@rexa-developer/tiks': + specifier: 'catalog:' + version: 0.3.0(react@19.2.7)(vue@3.5.35(typescript@7.0.2)) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7)