diff --git a/.cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md b/.cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md new file mode 100644 index 000000000..0b970e630 --- /dev/null +++ b/.cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md @@ -0,0 +1,47 @@ +--- +name: Widget Refresh Indicator +overview: "Add a board-header refresh indicator for widget/server-state queries using TanStack Query as the shared state source, then continue the PR #5768 pattern by removing widget-level blocking spinners where needed." +todos: + - id: switch-branch + content: After approval, switch the workspace to `agent/cebd0dad` and confirm the PR diff is present. + status: completed + - id: cache-hook + content: Create a TanStack Query cache-inspection hook that returns refresh counts and normalized query status rows. + status: completed + - id: header-ui + content: Add the hoverable orange/green header indicator immediately before the board edit button. + status: completed + - id: calendar-cleanup + content: Reconcile the calendar widget PR change with the new global indicator, removing redundant widget-local loading UI if appropriate. + status: completed + - id: verify + content: Run targeted typechecks and manually validate refresh, completion, hover details, and beep behavior. + status: completed +isProject: false +--- + +# Widget Refresh Indicator Plan + +## Findings +- PR [#5768](https://github.com/homarr-labs/homarr/pull/5768) is on `agent/cebd0dad` and currently only changes [packages/widgets/src/calendar/component.tsx](packages/widgets/src/calendar/component.tsx): `useSuspenseQuery` became `useQuery`, with a small widget-local loader. +- The current workspace is on `feat/add-integration-from-dashboard`, so implementation should first move to `agent/cebd0dad` after approval. +- Homarr already uses Jotai for small client UI state in [apps/nextjs/src/components/layout/header/burger.tsx](apps/nextjs/src/components/layout/header/burger.tsx), but there is no Zustand pattern. For this feature, TanStack Query itself should be the shared store. +- The client `QueryClient` lives in [apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx](apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx). TanStack Query v5 supports `useIsFetching()` for a global fetching count and `queryClient.getQueryCache().subscribe()` / `findAll()` for cache inspection. +- The board header actions are in [apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx](apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx); the indicator should be inserted immediately before ``, which places it to the left of the edit button. + +## Implementation Approach +- Add a small client hook/component, likely [apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx](apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx), that reads from `useQueryClient()` and subscribes to the query cache. +- Derive a normalized list from cached queries: query path/key, fetching status, `status`, `dataUpdatedAt`, stale state via the query object, error state, and integration IDs found in query inputs/data where present. +- Count active refreshes with `fetchStatus === "fetching"`, using a `useIsFetching()` value as a cheap re-render trigger and cache subscription for details. +- Render a Mantine `HeaderButton`/`ActionIcon` with a tiny `Indicator`: orange while one or more relevant queries are fetching, green briefly after a refresh batch completes, then idle/disabled when nothing is active. +- Use a Mantine `HoverCard` for the details: overall status, number of refreshing queries, last updated/stale state, and a compact list of current integration/widget query entries. +- Add a small Web Audio beep when the indicator transitions from fetching to complete. It should be best-effort and fail silently if the browser blocks audio before user interaction. +- Add translation keys only to [packages/translation/src/lang/en.json](packages/translation/src/lang/en.json); other locales fall back to English through the existing merge in [packages/translation/src/request.ts](packages/translation/src/request.ts). + +## Widget Migration Scope +- Keep PR #5768’s calendar behavior, but remove the widget-local loader if the header indicator fully replaces it. +- Do not attempt to convert all `useSuspenseQuery` widgets in the same pass unless the branch goal expands. The first version can show global activity for all query cache entries, while follow-up widget migrations replace blocking `useSuspenseQuery` with `useQuery` where each widget can render meaningful stale/empty content. + +## Verification +- Run a targeted typecheck such as `pnpm -F nextjs typecheck` and `pnpm -F widgets typecheck` after implementation. +- Skip lint cleanup unless explicitly requested, matching your preference to ignore linter errors. \ No newline at end of file diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 382b594b6..3b6ea7a37 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -37,7 +37,6 @@ import { useCategoryActions } from "~/components/board/sections/category/categor import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { HeaderButton } from "~/components/layout/header/button"; -import { QueryRefreshIndicator } from "~/components/layout/header/query-refresh-indicator"; export const BoardContentHeaderActions = () => { const [isEditMode] = useEditMode(); @@ -45,19 +44,13 @@ export const BoardContentHeaderActions = () => { const { hasChangeAccess } = useBoardPermissions(board); if (!hasChangeAccess) { - return ( - <> - - - - ); + return ; } return ( <> {isEditMode && } - diff --git a/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx b/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx index 5cb933330..3c9188a9b 100644 --- a/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx +++ b/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx @@ -1,35 +1,48 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { Box, Group, HoverCard, Indicator, ScrollArea, Stack, Text } from "@mantine/core"; -import { IconDatabase } from "@tabler/icons-react"; +import { forwardRef, useEffect, useState } from "react"; +import { Box, Indicator, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; import dayjs from "dayjs"; -import { HeaderButton } from "./button"; +import { useEditMode } from "@homarr/boards/edit-mode"; interface QueryStatusRow { id: string; - label: string; + section: string; + procedure: string; status: "pending" | "error" | "success"; fetchStatus: "fetching" | "paused" | "idle"; dataUpdatedAt: number; isStale: boolean; } -const widgetQueryPrefixes = new Set(["widget", "docker", "app", "integration"]); +const trackedQueryPrefixes = new Set(["widget", "integration"]); -const isWidgetQuery = (queryKey: readonly unknown[]): boolean => { +const isTrackedQuery = (queryKey: readonly unknown[]): boolean => { const first = queryKey[0]; - return Array.isArray(first) && first.length >= 1 && widgetQueryPrefixes.has(first[0]); + return Array.isArray(first) && first.length >= 1 && trackedQueryPrefixes.has(first[0] as string); }; -const extractQueryLabel = (queryKey: readonly unknown[]): string => { +const sectionLabels: Record = { + widget: "Widgets", + integration: "Integrations", +}; + +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +const parseQueryKey = (queryKey: readonly unknown[]): { section: string; procedure: string } => { const first = queryKey[0]; - if (Array.isArray(first)) { - return first.filter((s) => typeof s === "string").join("."); - } - return String(first); + if (!Array.isArray(first)) return { section: "Other", procedure: String(first) }; + + const parts = first.filter((s): s is string => typeof s === "string"); + const sectionKey = parts[0] ?? "other"; + const section = sectionLabels[sectionKey] ?? capitalize(sectionKey); + + const remaining = parts.slice(1); + const procedure = remaining.length > 0 ? remaining.map(capitalize).join(" — ") : sectionKey; + + return { section, procedure }; }; const statusColorMap: Record = { @@ -55,7 +68,7 @@ function useQueryCacheStatus() { useEffect(() => { const update = () => { const cache = queryClient.getQueryCache(); - const queries = cache.findAll({ type: "active" }).filter((q) => isWidgetQuery(q.queryKey)); + const queries = cache.findAll({ type: "active" }).filter((q) => isTrackedQuery(q.queryKey)); let fetching = 0; let errors = 0; @@ -63,15 +76,17 @@ function useQueryCacheStatus() { const nextRows: QueryStatusRow[] = queries.map((q) => { if (q.state.fetchStatus === "fetching") fetching++; if (q.state.status === "error") errors++; - const isStale = q.isStale(); - if (isStale) stale++; + const isQueryStale = q.isStale(); + if (isQueryStale) stale++; + const { section, procedure } = parseQueryKey(q.queryKey); return { id: q.queryHash, - label: extractQueryLabel(q.queryKey), + section, + procedure, status: q.state.status, fetchStatus: q.state.fetchStatus, dataUpdatedAt: q.state.dataUpdatedAt, - isStale, + isStale: isQueryStale, }; }); @@ -98,117 +113,87 @@ function useQueryCacheStatus() { return { ...snapshot, totalCount: snapshot.rows.length }; } -const audioCtxRef = { current: null as AudioContext | null }; - -function useCompletionBeep(fetchingCount: number) { - const prevRef = useRef(fetchingCount); - - useEffect(() => { - const wasFetching = prevRef.current > 0; - const nowIdle = fetchingCount === 0; - prevRef.current = fetchingCount; - - if (!wasFetching || !nowIdle) return; - - try { - if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { - audioCtxRef.current = new AudioContext(); - } - const ctx = audioCtxRef.current; - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.connect(gain); - gain.connect(ctx.destination); - osc.frequency.value = 880; - gain.gain.value = 0.08; - osc.start(); - osc.stop(ctx.currentTime + 0.08); - } catch { - // audio blocked before user interaction +function groupBySection(rows: QueryStatusRow[]): Map { + const groups = new Map(); + for (const row of rows) { + const existing = groups.get(row.section); + if (existing) { + existing.push(row); + } else { + groups.set(row.section, [row]); } - }, [fetchingCount]); + } + return groups; } -function useGreenFlash(fetchingCount: number) { - const [flash, setFlash] = useState(false); - const prevRef = useRef(fetchingCount); - - useEffect(() => { - const wasFetching = prevRef.current > 0; - const nowIdle = fetchingCount === 0; - prevRef.current = fetchingCount; - - if (!wasFetching || !nowIdle) return; - - setFlash(true); - const timer = setTimeout(() => setFlash(false), 1500); - return () => clearTimeout(timer); - }, [fetchingCount]); - - return flash; -} +const StatusDot = forwardRef( + ({ color, processing, ...others }, ref) => ( + + + + + + ), +); export const QueryRefreshIndicator = () => { - const { rows, widgetFetchingCount, errorCount, staleCount, totalCount } = useQueryCacheStatus(); - const showGreen = useGreenFlash(widgetFetchingCount); - useCompletionBeep(widgetFetchingCount); + const [isEditMode] = useEditMode(); + const { rows, widgetFetchingCount, errorCount, totalCount } = useQueryCacheStatus(); - if (totalCount === 0) return null; + if (isEditMode || totalCount === 0) return null; const isFetching = widgetFetchingCount > 0; - const color = isFetching ? "orange" : showGreen ? "green" : errorCount > 0 ? "red" : "green"; + const color = isFetching ? "orange" : errorCount > 0 ? "red" : "green"; + const grouped = groupBySection(rows); return ( - - - - - - - - - - - - - Integration status - - - {totalCount} queries · {widgetFetchingCount} fetching, {staleCount} stale, {errorCount} errors - - - - - - {rows.map((row) => ( - + + + + + + + {Array.from(grouped).map(([section, sectionRows], i) => ( +
+ {i > 0 && } + {section} + {sectionRows.map((row) => ( + ))} - - - - - +
+ ))} +
+
+
); }; -const QueryRow = ({ row }: { row: QueryStatusRow }) => { - const rowColor = statusColorMap[row.fetchStatus === "fetching" ? "fetching" : `idle_${row.status}`] ?? "gray"; +const QueryMenuItem = ({ row }: { row: QueryStatusRow }) => { + const rowColor = statusColorMap[ + row.fetchStatus === "fetching" ? "fetching" : `idle_${row.status}` + ] ?? "gray"; const updatedLabel = row.dataUpdatedAt > 0 ? dayjs(row.dataUpdatedAt).fromNow() : "never"; return ( - - - - {row.label} + + } + rightSection={ + + {updatedLabel} + + } + style={{ cursor: "default" }} + > + + {row.procedure} - - {updatedLabel} - - + ); }; diff --git a/apps/nextjs/src/components/layout/logo/board-logo.tsx b/apps/nextjs/src/components/layout/logo/board-logo.tsx index ad2f1ca2e..86bf94eeb 100644 --- a/apps/nextjs/src/components/layout/logo/board-logo.tsx +++ b/apps/nextjs/src/components/layout/logo/board-logo.tsx @@ -1,7 +1,10 @@ "use client"; +import { Group } from "@mantine/core"; + import { useRequiredBoard } from "@homarr/boards/context"; +import { QueryRefreshIndicator } from "../header/query-refresh-indicator"; import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; import type { LogoWithTitleProps } from "./logo"; import { Logo, LogoWithTitle } from "./logo"; @@ -33,11 +36,14 @@ export const BoardLogoWithTitle = ({ size, hideTitleOnMobile }: CommonLogoWithTi const board = useRequiredBoard(); const imageOptions = useImageOptions(); return ( - + + + + ); };