From 56cc021f98e5934059506598c4143ea3e93851c1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 18:44:30 +0200 Subject: [PATCH] fix: refine widget interactions --- .../src/components/widgets/interactions.tsx | 2 +- .../advanced-focus/advanced-focus.module.css | 22 ---------- .../board/advanced-focus/context.tsx | 3 ++ .../components/board/advanced-focus/wheel.ts | 11 +---- .../board/items/widget-query-scope.spec.ts | 11 +++-- e2e/board-advanced-interactions.spec.ts | 31 ++++++++++--- packages/translation/src/lang/en.json | 4 ++ .../src/advanced-focus-support.spec.ts | 1 + .../widgets/src/bazarr/component.module.css | 10 +++++ packages/widgets/src/bazarr/component.tsx | 9 +++- packages/widgets/src/bazarr/layout.spec.ts | 4 +- .../widgets/src/beszel-alerts/component.tsx | 7 ++- packages/widgets/src/beszel-alerts/index.ts | 1 + packages/widgets/src/common/locale.spec.ts | 4 ++ packages/widgets/src/common/locale.ts | 12 +++++- .../src/common/query-state-indicator.spec.ts | 22 ++++++++-- .../src/common/query-state-indicator.tsx | 23 ++++++---- .../widgets/src/common/query-state.spec.ts | 10 +---- packages/widgets/src/common/query-state.ts | 4 -- .../widgets/src/weather/component.spec.ts | 14 ++++++ packages/widgets/src/weather/icon.tsx | 43 +++++++++++++++---- 21 files changed, 163 insertions(+), 85 deletions(-) diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx index b0c08e51c..86aeb5451 100644 --- a/apps/docs/src/components/widgets/interactions.tsx +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -54,7 +54,7 @@ export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: {advancedView && (

Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. - Touch devices expose a maximize control; compact-only widgets adapt in place instead. + On touch devices, use the widget menu; compact-only widgets adapt in place instead.

)} diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css index a16e2ba12..388212e33 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -76,34 +76,12 @@ pointer-events: none; } -.surfaceControls { - position: absolute; - inset: 0; - z-index: 2; - pointer-events: none; -} - .closeButton { position: absolute; z-index: 2; pointer-events: auto; } -.touchButton { - position: absolute; - right: 8px; - bottom: 8px; - z-index: 2; - display: none; - pointer-events: auto; -} - -@media (any-pointer: coarse) { - .touchButton { - display: inline-flex; - } -} - @keyframes focus-backdrop-in { from { opacity: 0; diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 39143a2a8..d034b9c36 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -37,6 +37,8 @@ interface AdvancedFocusContextValue { const AdvancedFocusContext = createContext(null); const HOVER_DELAY_MS = 150; const CLOSE_DURATION_MS = 180; +const interactiveSelector = + "a[href], button, input, textarea, select, [contenteditable='true'], [role='button'], [role='link'], [role='menuitem'], [role='option']"; const isEditableTarget = (target: EventTarget | null) => target instanceof HTMLElement && @@ -131,6 +133,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { const handlePointerMove = (event: PointerEvent) => { const current = activeRef.current; if (!shiftHeldRef.current || current?.activation !== "preview") return; + if (event.target instanceof Element && event.target.closest(interactiveSelector)) return; const underlyingSource = document .elementsFromPoint(event.clientX, event.clientY) diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts index e6cb1946e..6f5d598de 100644 --- a/apps/nextjs/src/components/board/advanced-focus/wheel.ts +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -22,14 +22,5 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (current === root) break; current = current.parentElement; } - - // A nested control can reach its edge before the widget's main Mantine viewport. - // Hand the gesture to that viewport instead of making Shift+wheel feel stuck. - const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find( - (element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta), - ); - if (!viewport) return false; - - viewport.scrollTop += delta; - return true; + return false; }; diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts index ae135ea57..de667b350 100644 --- a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts @@ -27,11 +27,16 @@ const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scop describe("matchesWidgetItemQuery", () => { test("matches only the current Custom API definition", () => { const scope = createScope({ options: { definitionId: "definition-1" } }); + const input = { boardId: "board-1", itemId: "item-1", definitionId: "definition-1" }; - expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe( - true, + expect(matches("customApi", ["widget", "customApi", "getData"], input, scope)).toBe(true); + expect( + matches("customApi", ["widget", "customApi", "getData"], { ...input, definitionId: "definition-2" }, scope), + ).toBe(false); + expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, itemId: "item-2" }, scope)).toBe( + false, ); - expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe( + expect(matches("customApi", ["widget", "customApi", "getData"], { ...input, boardId: "board-2" }, scope)).toBe( false, ); }); diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index e94f6cfb6..6f2731629 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -86,6 +86,7 @@ describe("Board advanced interactions", () => { await expect(previewSurface).toHaveCSS("animation-name", "none"); const compactBounds = await widget.boundingBox(); const advancedBounds = await previewSurface.boundingBox(); + if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds"); expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0); await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); await expect(dimmingOverlay).toBeVisible(); @@ -105,6 +106,24 @@ describe("Board advanced interactions", () => { const otherBounds = await otherWidget.boundingBox(); expect(otherBounds).not.toBeNull(); if (!otherBounds) throw new Error("Bookmarks widget has no bounds"); + await previewSurface.evaluate( + (element, position) => { + const probe = document.createElement("button"); + probe.dataset.interactivePreviewProbe = "true"; + probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`; + element.append(probe); + }, + { + left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20, + top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20, + }, + ); + const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']"); + await interactivePreviewProbe.hover(); + await expect(previewSurface).toBeVisible(); + await expect(bookmarksPreviewSurface).toBeHidden(); + await interactivePreviewProbe.evaluate((element) => element.remove()); + await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4); await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2); await expect(previewSurface).toBeHidden(); await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 }); @@ -130,17 +149,17 @@ describe("Board advanced interactions", () => { 0, ); await manualSurface.evaluate((element) => { + const mainViewport = document.createElement("div"); + mainViewport.dataset.scrollbars = "y"; + mainViewport.style.cssText = "height:80px;overflow-y:auto"; + const probe = document.createElement("div"); probe.dataset.shiftScrollProbe = "true"; probe.style.cssText = "height:80px;overflow-y:auto"; probe.innerHTML = '
'; probe.addEventListener("wheel", (event) => event.stopPropagation()); - element.append(probe); - - const mainViewport = document.createElement("div"); - mainViewport.dataset.scrollbars = "y"; - mainViewport.style.cssText = "height:80px;overflow-y:auto"; - mainViewport.innerHTML = '
'; + mainViewport.append(probe); + mainViewport.insertAdjacentHTML("beforeend", '
'); element.append(mainViewport); const unrelated = document.createElement("div"); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1368070be..9c4e81553 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1871,6 +1871,10 @@ "success": "Success", "beta": "Beta", "error": "Error", + "widgetQuery": { + "loading": "Loading widget data", + "stale": "Could not refresh {widget}; showing saved data" + }, "action": { "add": "Add", "apply": "Apply", diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts index 1b8e7a73f..2ee1dcc84 100644 --- a/packages/widgets/src/advanced-focus-support.spec.ts +++ b/packages/widgets/src/advanced-focus-support.spec.ts @@ -6,6 +6,7 @@ import { widgetImports } from "."; const compactOnlyWidgetKinds = [ "app", "bazarr", + "beszelAlerts", "beszelSystemGrid", "beszelSystemStats", "beszelSystemTable", diff --git a/packages/widgets/src/bazarr/component.module.css b/packages/widgets/src/bazarr/component.module.css index 9b5857379..a41eeb5bf 100644 --- a/packages/widgets/src/bazarr/component.module.css +++ b/packages/widgets/src/bazarr/component.module.css @@ -33,6 +33,16 @@ overflow: hidden; } +.grid[data-short] .statTile { + flex-direction: row; + gap: 3px; + padding: 3px 4px; +} + +.grid[data-short] .statLabel { + text-align: left; +} + .statTileWarning { background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5)); } diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index c4914bfff..4d4e9cedd 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -26,6 +26,7 @@ const statIcons = { } as const; const gridColsByWidth = [ + { minWidth: 640, cols: 4 }, { minWidth: 380, cols: 2 }, { minWidth: 0, cols: 1 }, ] as const; @@ -74,7 +75,11 @@ export default function BazarrWidget({ integrationIds, options, width, height }: return (
-
+
{visibleStatKeys.map((statKey) => { const Icon = statIcons[statKey]; const value = statValues[statKey]; @@ -99,7 +104,7 @@ export function getGridCols(width: number, height: number, itemCount: number): n if (itemCount <= 1) return 1; const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; - const maxColumnsByWidth = Math.max(1, Math.floor(width / 160)); + const maxColumnsByWidth = Math.max(1, Math.floor(width / 100)); const maxRowsByHeight = Math.max(1, Math.floor(height / 72)); const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight); return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit)); diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts index e3552a737..f5e03fba4 100644 --- a/packages/widgets/src/bazarr/layout.spec.ts +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component"; describe("Bazarr responsive layout", () => { test("uses two columns for a short widget so all stats remain visible", () => { - expect(getGridCols(220, 100, 4)).toBe(1); + expect(getGridCols(220, 100, 4)).toBe(2); }); test("uses four columns in a wide advanced surface", () => { - expect(getGridCols(800, 400, 4)).toBe(2); + expect(getGridCols(800, 400, 4)).toBe(4); }); test("sizes icons from the constrained dimension", () => { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index f7743b935..23243f35e 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -50,7 +50,6 @@ export default function BeszelAlertsWidget({ integrationIds, isEditMode, height, - displayMode = "compact", }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( @@ -85,9 +84,9 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260; - const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360); - const showAlertDescriptions = displayMode === "advanced" || height >= 190; + const showOkAlerts = triggeredAlerts.length === 0 || height >= 260; + const showHistory = options.showHistory && height >= 360; + const showAlertDescriptions = height >= 190; if (isPending) { return ( diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index f5fdc9d01..db1e791b2 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,6 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, + supportsAdvancedFocus: false, queryKey: [["widget", "beszel", "getAlerts"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { diff --git a/packages/widgets/src/common/locale.spec.ts b/packages/widgets/src/common/locale.spec.ts index d9fa119c7..9cae44936 100644 --- a/packages/widgets/src/common/locale.spec.ts +++ b/packages/widgets/src/common/locale.spec.ts @@ -21,6 +21,10 @@ describe("localized widget formatting", () => { expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05"); }); + test("keeps date-only values on the requested calendar day", () => { + expect(formatLocalizedDate("2026-08-03", "en-US", { month: "long", day: "numeric" })).toBe("August 3"); + }); + test("localizes compact numbers", () => { expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M"); expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2"); diff --git a/packages/widgets/src/common/locale.ts b/packages/widgets/src/common/locale.ts index e076cedd2..c0f478f47 100644 --- a/packages/widgets/src/common/locale.ts +++ b/packages/widgets/src/common/locale.ts @@ -1,5 +1,7 @@ export type LocalizedDateValue = Date | number | string; +const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/; + interface LocalizedTimeOptions { hour12?: boolean; includeSeconds?: boolean; @@ -7,7 +9,7 @@ interface LocalizedTimeOptions { } const toValidDate = (value: LocalizedDateValue): Date | undefined => { - const date = value instanceof Date ? value : new Date(value); + const date = value instanceof Date ? value : new Date(typeof value === "string" && dateOnlyPattern.test(value) ? `${value}T00:00:00Z` : value); return Number.isNaN(date.getTime()) ? undefined : date; }; @@ -18,7 +20,13 @@ export const formatLocalizedDate = ( fallback = "?", ): string => { const date = toValidDate(value); - return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback; + if (!date) return fallback; + + const normalizedOptions = + typeof value === "string" && dateOnlyPattern.test(value) && options.timeZone === undefined + ? { ...options, timeZone: "UTC" } + : options; + return new Intl.DateTimeFormat(locale, normalizedOptions).format(date); }; export const formatLocalizedTime = ( diff --git a/packages/widgets/src/common/query-state-indicator.spec.ts b/packages/widgets/src/common/query-state-indicator.spec.ts index f902aafc9..86da6f64b 100644 --- a/packages/widgets/src/common/query-state-indicator.spec.ts +++ b/packages/widgets/src/common/query-state-indicator.spec.ts @@ -5,10 +5,14 @@ import { createRoot } from "react-dom/client"; import { MantineProvider } from "@mantine/core"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { WidgetQueryErrorIndicator } from "./query-state-indicator"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "./query-state-indicator"; vi.mock("@homarr/translation/client", () => ({ - useScopedI18n: () => (key: string) => key, + useScopedI18n: () => (key: string, values?: { widget?: string }) => { + if (key === "stale") return `Could not refresh ${values?.widget}; showing saved data`; + if (key === "loading") return "Loading widget data"; + return key; + }, })); const matchMedia = (query: string) => ({ @@ -50,7 +54,19 @@ describe("WidgetQueryErrorIndicator", () => { ), ); - expect(host.querySelector("[aria-label='Weather']")).not.toBeNull(); + expect(host.querySelector("[aria-label='Could not refresh Weather; showing saved data']")).not.toBeNull(); expect(host.textContent).not.toMatch(/password|private\.local|token=secret/); }); + + test("announces the loading state to assistive technology", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", matchMedia); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + await act(async () => root?.render(createElement(MantineProvider, null, createElement(WidgetQueryLoadingState)))); + + expect(host.querySelector("output")?.textContent).toContain("Loading widget data"); + }); }); diff --git a/packages/widgets/src/common/query-state-indicator.tsx b/packages/widgets/src/common/query-state-indicator.tsx index 9196c2c69..dbd2c5a69 100644 --- a/packages/widgets/src/common/query-state-indicator.tsx +++ b/packages/widgets/src/common/query-state-indicator.tsx @@ -1,8 +1,10 @@ "use client"; -import { Box, Center, Loader, Tooltip } from "@mantine/core"; +import { ActionIcon, Center, Loader, Tooltip, VisuallyHidden } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + interface WidgetQueryErrorIndicatorProps { error: unknown; label: string; @@ -10,29 +12,34 @@ interface WidgetQueryErrorIndicatorProps { /** Localized warning for stale or optional-query failures. Never renders the raw error. */ export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) { + const t = useScopedI18n("common.widgetQuery"); if (!error) return null; - const accessibleLabel = label; + const accessibleLabel = t("stale", { widget: label }); return ( - - + ); } export function WidgetQueryLoadingState() { + const t = useScopedI18n("common.widgetQuery"); return ( -
+
+ {t("loading")}
); } diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts index 5e49af84a..c90c0ae50 100644 --- a/packages/widgets/src/common/query-state.spec.ts +++ b/packages/widgets/src/common/query-state.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "./query-state"; describe("getUsableWidgetQueryData", () => { test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => { @@ -29,11 +29,3 @@ describe("isInitialWidgetQueryPending", () => { expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false); }); }); - -describe("hasStaleWidgetQueryError", () => { - test("only marks failures that retain usable cached data", () => { - expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true); - expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false); - expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false); - }); -}); diff --git a/packages/widgets/src/common/query-state.ts b/packages/widgets/src/common/query-state.ts index d49ea600b..a83066c0b 100644 --- a/packages/widgets/src/common/query-state.ts +++ b/packages/widgets/src/common/query-state.ts @@ -16,7 +16,3 @@ export const getUsableWidgetQueryData = (query: /** True only before the first result; successful empty values and cached data are both ready to render. */ export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean => isPending && data === undefined && !error; - -/** A failed background refresh still has usable cached data, but should remain visible to the user. */ -export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean => - error !== null && error !== undefined && data !== undefined; diff --git a/packages/widgets/src/weather/component.spec.ts b/packages/widgets/src/weather/component.spec.ts index 0340625ad..fc0d219a2 100644 --- a/packages/widgets/src/weather/component.spec.ts +++ b/packages/widgets/src/weather/component.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component"; +import { formatWeatherDate } from "./icon"; describe("weather formatting", () => { it("preserves valid zero temperatures", () => { @@ -19,4 +20,17 @@ describe("weather formatting", () => { 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"], + ["MMM D", "Aug 3"], + ["D MMM", "3 Aug"], + ["DD/MM/YYYY", "03/08/2026"], + ["MM/DD/YYYY", "08/03/2026"], + ["DD/MM", "03/08"], + ["MM/DD", "08/03"], + ] as const)("honors the saved %s date format", (pattern, expected) => { + expect(formatWeatherDate("2026-08-03", "en-US", pattern)).toBe(expected); + }); }); diff --git a/packages/widgets/src/weather/icon.tsx b/packages/widgets/src/weather/icon.tsx index 1389281ed..d2c5a28b7 100644 --- a/packages/widgets/src/weather/icon.tsx +++ b/packages/widgets/src/weather/icon.tsx @@ -19,7 +19,6 @@ import type { TranslationObject } from "@homarr/translation"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; -import { formatLocalizedDate } from "../common/locale"; import type { WidgetProps } from "../definition"; interface WeatherIconProps { @@ -130,16 +129,42 @@ export const formatWeatherDate = ( pattern: WidgetProps<"weather">["options"]["dateFormat"] | undefined, ) => { if (!value) return "?"; + const resolvedPattern = pattern ?? "dddd, MMMM D"; + const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value); + if (Number.isNaN(date.getTime())) return "?"; - const options: Intl.DateTimeFormatOptions = pattern?.includes("dddd") - ? { weekday: "long", month: "long", day: "numeric" } - : pattern?.includes("YYYY") - ? { year: "numeric", month: "2-digit", day: "2-digit" } - : pattern?.includes("MMM") - ? { month: "short", day: "numeric" } - : { month: "2-digit", day: "2-digit" }; + const parts = new Intl.DateTimeFormat(locale, { + weekday: "long", + year: "numeric", + month: resolvedPattern.includes("MMMM") ? "long" : resolvedPattern.includes("MMM") ? "short" : "2-digit", + day: resolvedPattern.includes("DD") ? "2-digit" : "numeric", + timeZone: "UTC", + }) + .formatToParts(date) + .reduce>((result, part) => { + if (part.type !== "literal") result[part.type] = part.value; + return result; + }, {}); - return formatLocalizedDate(value, locale, options); + switch (resolvedPattern) { + case "dddd, D MMMM": + return `${parts.weekday}, ${parts.day} ${parts.month}`; + case "MMM D": + return `${parts.month} ${parts.day}`; + case "D MMM": + return `${parts.day} ${parts.month}`; + case "DD/MM/YYYY": + return `${parts.day}/${parts.month}/${parts.year}`; + case "MM/DD/YYYY": + return `${parts.month}/${parts.day}/${parts.year}`; + case "DD/MM": + return `${parts.day}/${parts.month}`; + case "MM/DD": + return `${parts.month}/${parts.day}`; + case "dddd, MMMM D": + default: + return `${parts.weekday}, ${parts.month} ${parts.day}`; + } }; interface WeatherDefinitionType {