From a2668740c06dceeb599d7e86e1a95e48e25d2914 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 28 May 2026 15:31:29 +0200 Subject: [PATCH] feat(ui): add global query refresh indicator to board header Replace per-widget loading spinners with a shared header indicator that shows live TanStack Query cache status for all widget-related queries. Includes orange/green dot, completion beep, and hover card with per-query staleness and error details. --- .../boards/(content)/_header-actions.tsx | 9 +- .../layout/header/query-refresh-indicator.tsx | 218 ++++++++++++++++++ packages/widgets/src/calendar/component.tsx | 7 +- 3 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx 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 3b6ea7a37..382b594b6 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -37,6 +37,7 @@ 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(); @@ -44,13 +45,19 @@ 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 new file mode 100644 index 000000000..04678b736 --- /dev/null +++ b/apps/nextjs/src/components/layout/header/query-refresh-indicator.tsx @@ -0,0 +1,218 @@ +"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 { useQueryClient } from "@tanstack/react-query"; +import dayjs from "dayjs"; + +import { HeaderButton } from "./button"; + +interface QueryStatusRow { + id: string; + label: string; + status: "pending" | "error" | "success"; + fetchStatus: "fetching" | "paused" | "idle"; + dataUpdatedAt: number; + isStale: boolean; +} + +const widgetQueryPrefixes = new Set(["widget", "docker", "app", "integration"]); + +const isWidgetQuery = (queryKey: readonly unknown[]): boolean => { + const first = queryKey[0]; + return Array.isArray(first) && first.length >= 1 && widgetQueryPrefixes.has(first[0]); +}; + +const extractQueryLabel = (queryKey: readonly unknown[]): string => { + const first = queryKey[0]; + if (Array.isArray(first)) { + return first.filter((s) => typeof s === "string").join("."); + } + return String(first); +}; + +const statusColorMap: Record = { + fetching: "orange", + idle_success: "green", + idle_error: "red", + idle_pending: "gray", +}; + +interface CacheSnapshot { + rows: QueryStatusRow[]; + widgetFetchingCount: number; + errorCount: number; + staleCount: number; +} + +const emptyCacheSnapshot: CacheSnapshot = { rows: [], widgetFetchingCount: 0, errorCount: 0, staleCount: 0 }; + +function useQueryCacheStatus() { + const queryClient = useQueryClient(); + const [snapshot, setSnapshot] = useState(emptyCacheSnapshot); + + useEffect(() => { + const update = () => { + const cache = queryClient.getQueryCache(); + const queries = cache.findAll({ type: "active" }).filter((q) => isWidgetQuery(q.queryKey)); + + let fetching = 0; + let errors = 0; + let stale = 0; + 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++; + return { + id: q.queryHash, + label: extractQueryLabel(q.queryKey), + status: q.state.status, + fetchStatus: q.state.fetchStatus, + dataUpdatedAt: q.state.dataUpdatedAt, + isStale, + }; + }); + + setSnapshot({ rows: nextRows, widgetFetchingCount: fetching, errorCount: errors, staleCount: stale }); + }; + + update(); + + let rafId: number | null = null; + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + update(); + }); + }); + + return () => { + unsubscribe(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [queryClient]); + + 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 + } + }, [fetchingCount]); +} + +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; +} + +export const QueryRefreshIndicator = () => { + const { rows, widgetFetchingCount, errorCount, staleCount, totalCount } = useQueryCacheStatus(); + const showGreen = useGreenFlash(widgetFetchingCount); + useCompletionBeep(widgetFetchingCount); + + if (totalCount === 0) return null; + + const isFetching = widgetFetchingCount > 0; + const color = isFetching ? "orange" : showGreen ? "green" : errorCount > 0 ? "red" : "green"; + + return ( + + + + + + + + + + + + + Integration status + + + {totalCount} queries · {widgetFetchingCount} fetching, {staleCount} stale, {errorCount} errors + + + + + + {rows.map((row) => ( + + ))} + + + + + + ); +}; + +const QueryRow = ({ 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} + + + {updatedLabel} + + + ); +}; diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index b287d2324..1ee33f68a 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -39,7 +39,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const [data] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(input, { + const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -61,7 +61,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: }, }); - const events = useMemo(() => data.flatMap((item) => item.events), [data]); + const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); return ; }; @@ -156,9 +156,6 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar return (