From cb5ae65f8a497cde04c4e3c178274e41fff099b1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 17:13:31 +0200 Subject: [PATCH] refactor(ui): simplify indicator to status dot on board logo Use Mantine Indicator with processing animation instead of custom CSS. Switch from HoverCard to Menu with grouped sections. Remove beep, position dot next to board logo, hide in edit mode. --- .../widget_refresh_indicator_ffb2b58d.plan.md | 47 ++++ .../boards/(content)/_header-actions.tsx | 9 +- .../layout/header/query-refresh-indicator.tsx | 207 ++++++++---------- .../src/components/layout/logo/board-logo.tsx | 18 +- 4 files changed, 156 insertions(+), 125 deletions(-) create mode 100644 .cursor/plans/widget_refresh_indicator_ffb2b58d.plan.md 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 ( - + + + + ); };