diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx
index 145e221ea..359d189e6 100644
--- a/apps/docs/docs/management/api/index.mdx
+++ b/apps/docs/docs/management/api/index.mdx
@@ -84,6 +84,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
+## Partial integration failures
+
+Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
+an optional `error` on a failed integration; media-request responses expose a `failedIntegrations` array, and the latest
+requests endpoint returns its items in `requests`. When every selected integration fails, the query returns an error instead
+of a partial response.
+
### Saving dashboard layouts
The dashboard editor saves item positions and sections as one board update.
diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx
index 68f3a27ab..4bd3aa6d1 100644
--- a/apps/docs/docs/widgets/media-request-list/index.mdx
+++ b/apps/docs/docs/widgets/media-request-list/index.mdx
@@ -46,6 +46,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
+- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.ts b/apps/nextjs/src/components/board/items/widget-query-scope.ts
index 03ae7a1d3..fd7c9edb3 100644
--- a/apps/nextjs/src/components/board/items/widget-query-scope.ts
+++ b/apps/nextjs/src/components/board/items/widget-query-scope.ts
@@ -1,7 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
-import { hashKey } from "@tanstack/react-query";
-import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
+import type { WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
+import { normalizeWidgetQuery, widgetQueryValueEquals } from "@homarr/widgets";
export const matchesWidgetItemQuery = (
queryKey: QueryKey,
@@ -16,15 +16,6 @@ export const matchesWidgetItemQuery = (
return matchesQueryInput(query.input, scope);
};
-const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
- const path = queryKey[0];
- if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
-
- const queryKeyOptions = queryKey[1];
- const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
- return { path, input };
-};
-
const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
const candidatePath = candidate[0];
return (
@@ -36,7 +27,7 @@ const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) =>
const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
if (input == null) return true;
if (Array.isArray(input)) {
- return isEqual(input, scope.integrationIds) || containsValue(scope.options, input);
+ return widgetQueryValueEquals(input, scope.integrationIds) || containsValue(scope.options, input);
}
if (!isRecord(input)) {
@@ -62,7 +53,7 @@ const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
continue;
}
if (key === "integrationIds") {
- if (!isEqual(value, scope.integrationIds)) return false;
+ if (!widgetQueryValueEquals(value, scope.integrationIds)) return false;
continue;
}
@@ -81,14 +72,12 @@ const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =
const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
const containsValue = (container: unknown, expected: unknown, seen = new WeakSet