+
{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 {