17 KiB
fe0: Dashboard data refresh and API polling
This document explains why the browser repeatedly calls /api/applications and /api/notifications/unread-count on the dashboard, how that fits the React + TanStack Query + Axios stack, and design tradeoffs for tuning behavior.
It also encodes a stabilization plan for frontend, backend pressure, and predictable data loading—refined from a stability review (assets/docs/feedback.md) focused on removing implicit globals, polite polling, and consistent refresh semantics.
1. High-level flow
flowchart LR
subgraph ui [Dashboard UI]
D[Dashboard.tsx]
AAL[Admin ApprovedApplicationsList]
CAL[Council ApprovedApplicationsList]
NB[NotificationBell]
end
subgraph rq [TanStack Query]
QApps["useQuery applications"]
QNotif["useQuery unread count"]
end
subgraph http [HTTP]
AC[apiClient axios]
BE["Backend APIs"]
end
D -->|admin role| AAL
D -->|editor role| CAL
D -.-> NB
AAL --> QApps
NB --> QNotif
QApps --> AC
QNotif --> AC
AC --> BE
Dashboard.tsxchooses which shell to render by role: admin sees the admin applications list (inbox), editor (council) sees a different list implementation, applicant sees the registration workspace (no inbox polling for applications in the same way).apiClient(fe0/src/shared/api/client.ts) is the shared Axios instance used by queries and mutations.- TanStack Query caches by
queryKey, runsqueryFnon mount, and can refetch on an interval or when the window regains focus, depending on per-query options and explicitQueryClient.defaultOptions(see §7).
2. What triggers repeated /api/applications (admin inbox)
The route /dashboard for users with the admin role renders:
fe0/src/pages/Dashboard.tsx → AdminApprovedApplicationsList with lifecycle="inbox".
The list loads data with useQuery in fe0/src/components/admin/ApprovedApplicationsList.tsx.
Current behavior (as implemented today)
| Option | Value | Effect |
|---|---|---|
queryKey |
["applications", filters] |
Separate cache per filter set; must be a stable key—see §11. |
refetchInterval |
10 * 1000 (10 seconds) |
Automatic polling while mounted. Target: visibility-aware + optional jitter (§8, §12). |
refetchOnWindowFocus |
"always" (today) |
Refetch on every focus regardless of staleness—high load; target is true + sensible staleTime (§8). |
refetchOnReconnect |
true |
Refetch when the browser regains network after offline. |
placeholderData |
(previous) => previous |
Keeps showing the last page while a refetch runs (less table flicker). Keep this. |
So the “every few seconds” pattern you see in DevTools is intentional polling, not a runaway bug—but the combination of 10s polling + "always" focus multiplies traffic when admins tab frequently (§8).
Same component, other lifecycles
ApprovedApplicationsList is also reused for the decided list (lifecycle="decided") from DecidedApplicationsPanel. The same refetchInterval: 10s applies there as well—polling is tied to the component, not only the inbox title.
3. Council dashboard: different refresh strategy (target: unify)
Editors (hasRole("editor")) get CouncilApprovedApplicationsList (fe0/src/components/council/ApprovedApplicationsList.tsx).
That file’s applicationsQuery does not set refetchInterval today. Updates are driven more by:
- Normal Query behavior (mount, default focus rules, etc.).
reportSyncQuery: when itsdataUpdatedAtchanges, an effect runsqueryClient.invalidateQueries({ queryKey: ["applications"] }), which pulls a fresh/api/applicationswithout a fixed timer.
Problem: admin (time-based polling) and council (event-driven invalidation) are two mental models for similar surfaces, in different files—cognitive load, bug asymmetry, and drift (fixes in one place may not land in the other).
Target architecture (single strategy everywhere):
- Primary: invalidation on mutations (
approve,reject,submit,assign, etc.) plus invalidation on lightweight report sync / version signals where applicable. - Secondary: a slow safety-net poll (e.g. 60–120s, visibility-aware, optionally jittered) so a missed invalidation does not leave the UI stale forever.
- Later (product-driven only): SSE behind
apiClientif true realtime is required—one long-lived connection per tab scales better than many short polls; WebSockets only if the server must push high-frequency updates.
Until unified, treat both admin and council lists as in scope for isFetching audits and query-key stability (§10, §11).
4. Notifications unread count
fe0/src/components/notifications/NotificationBell.tsx:
queryKey:["notifications-unread-count"]queryFn:fetchNotificationsUnreadCount→GET /api/notifications/unread-countrefetchInterval: 60_000 ms (once per minute)refetchOnWindowFocus:truestaleTime: 30_000 ms
NotificationManager.tsx uses a similar 60s interval for the list and calls queryClient.invalidateQueries({ queryKey: ["notifications-unread-count"] }) after mutations so the bell can update sooner than the next minute tick. This invalidation pattern is the model for other features (§3).
5. Other polling in the admin area
These are separate from the inbox but follow the same idea (“keep dashboards somewhat fresh”):
| Location | Interval | Purpose |
|---|---|---|
OverviewTab.tsx |
30s | Health/status style data |
AIManagementTab.tsx |
30s | AI service health |
NotificationBell / NotificationManager |
60s | Notifications |
Target: centralize intervals in one module (e.g. fe0/src/shared/config/polling.ts) so ops and load tests can tune without hunting magic numbers across files (§12).
6. client.ts dev logging (stability and privacy)
In fe0/src/shared/api/client.ts, the Axios response interceptor logs successful responses when import.meta.env.DEV is true.
Risks: full data payloads on large lists flood the console; a misconfigured deploy that runs “dev-like” builds could leak user data to browser consoles.
Target:
- Sample or summarize responses in dev; prefer
console.debugoverconsole.logfor high-volume paths so DevTools defaults stay readable. - Guard production with a build-time assertion (or strict env contract), not
import.meta.env.DEValone.
7. QueryClient defaults (critical: one entrypoint, explicit defaults)
Having two App.tsx files with different QueryClient configuration is a silent global switch: a refactor, import cleanup, or rebase can change refetch behavior app-wide without touching feature code—and per-query refetchInterval would still “look” correct in review.
Required actions (do first):
- Pick one entrypoint. Remove the duplicate in the same change set; do not leave a long-lived TODO.
- Prevent regression: CI or ESLint
no-restricted-importsforbidding the removed path if it could be revived. - Set explicit
defaultOptionson the survivingQueryClient, even when values match library defaults—implicit defaults are a major-version upgrade hazard for TanStack Query.
Illustrative shape (adjust staleTime / gcTime / retry helpers to match product decisions):
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, err) => {
if (isAuthError(err)) return false;
return failureCount < 2;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
},
mutations: { retry: false },
},
});
Then refetchInterval, refetchOnWindowFocus: true, and other overrides become deliberate opt-ins at the query level.
Today’s split (legacy): fe0/src/main.tsx imports fe0/src/App.tsx (new QueryClient() with no defaults). fe0/src/app/App.tsx uses different defaults and is not wired from main.tsx until consolidated.
8. Polling and focus: polite defaults (frontend + backend load)
Why "always" on focus is wrong for an inbox
refetchOnWindowFocus: "always" refetches on every focus event regardless of staleness. With 10s polling, admins who tab in and out can drive 12–20+ requests/minute per tab; many admins at start of shift create synchronized bursts the backend cannot absorb gracefully.
Target: use true (refetch only when stale) with a sensible staleTime for that query. Approvals inboxes are not trading screens; the UX difference is negligible; server load is not.
Visibility-aware polling (default pattern, not optional)
Background tabs still run timers (throttling varies). Dashboards left open all day waste work that scales with headcount.
Default for every polling query: pause when the document is hidden.
function useVisibilityAwareInterval(ms: number) {
return () => (document.visibilityState === "visible" ? ms : false);
}
Use the function form of refetchInterval supported by TanStack Query so engineers do not re-implement this ad hoc.
Jitter (optional smoothing)
Fixed intervals from mount align across users (start of shift). ±10–20% jitter on poll delays spreads load with negligible UX impact—worth adopting once concurrent admin count grows.
9. HTTP, timeouts, retries, and auth (document + implement gaps)
The happy path is documented elsewhere; stability requires explicit policy—even when nothing fails in tests.
| Concern | Risk if ignored | Target |
|---|---|---|
| No Axios timeout | Hung requests pile up; 10s polling stacks in-flight work; per-host concurrency pins the tab; UI looks frozen. | Set explicit timeouts on apiClient (or per-route overrides for long operations). |
| Default Query retries | TanStack Query retries 3× by default; a bad poll tick can amplify load during an outage (4 quick failures per cycle). | Align retry / retryDelay with defaultOptions (§7); cap retries on read-heavy queries. |
| 401 / 403 | Silent loops: auth expired → poll → 401 → retry → poll again; “dashboard broken” reports. | Never retry auth failures; interceptor should logout / redirect / refresh in one documented path—no infinite poll on unauthenticated sessions. |
| Offline | refetchOnReconnect: true helps, but users may see blank data and assume loss. |
Surface offline / reconnect in UI where lists are empty or stale. |
Add or link implementation details in fe0/src/shared/api/client.ts and auth helpers as these behaviors are codified.
10. isLoading vs isFetching (UI coupling)
Pattern problem: wiring isFetching from a list query into controls that are conceptually independent (export, filters, “new application”, pagination) causes bugs that localhost hides (fast requests → flicker too quick to see) and cloud exposes (slow polls keep isFetching true → controls look “stuck refreshing”).
Rules of thumb:
isLoading(no cached data yet) is usually safe for gating skeletons or first-load UI.isFetchingshould almost never disable user-initiated actions; use a subtle indicator or local loading only for that action (e.g. export-only state).
Action: audit every consumer of ["applications", ...] (and similar list keys) for isFetching / isLoading. Consider a lint rule or review checklist: if a button is disabled on isFetching, require an inline justification.
11. Query key stability
If filters is an object literal created in render ({ status, page, q }), its reference changes every render. TanStack Query may treat the key as new every time → extra requests, refetch on keystrokes, refetch on unrelated state updates.
Mitigations:
useMemofor the filters object keyed by primitive fields, or- Prefer primitive keys:
["applications", status, page, q, ...]—verbose but serializable and easy to debug.
Encode the chosen rule in team TanStack Query conventions.
12. Centralize polling constants
Intervals such as 10s, 30s, 60s scattered across files are hard to tune for load tests or incidents.
Target module (example):
// fe0/src/shared/config/polling.ts
export const POLL_INTERVALS = {
adminInbox: 10_000,
notificationsCount: 60_000,
notificationsList: 60_000,
adminOverview: 30_000,
aiHealth: 30_000,
} as const;
Optionally drive values from env later without touching every callsite.
13. Phased implementation order
Pragmatic sequencing when work must land incrementally (from stability review):
- First — One
App.tsx, explicitQueryClient.defaultOptions, CI/ESLint guard against the removed path (§7). - Next —
isFetchingaudit; visibility-aware polling helper; replace admin inbox"always"withtrue+staleTime(§8, §10). - Then — Centralize
POLL_INTERVALS; document and implement timeout / retry / auth behavior (§9, §12); verify query key stability (§11). - Horizon — Unify admin + council refresh: invalidation primary, slow safety-net poll (§3); SSE only if realtime becomes a product requirement.
14. Quick file map
| File | Role |
|---|---|
fe0/src/pages/Dashboard.tsx |
Role-based dashboard shell; wires admin inbox list. |
fe0/src/components/admin/ApprovedApplicationsList.tsx |
Admin /api/applications query; 10s poll, focus "always" today—targets in §8, §10, §11. |
fe0/src/components/council/ApprovedApplicationsList.tsx |
Council list; invalidates on report sync—unify with §3. |
fe0/src/components/notifications/NotificationBell.tsx |
Unread count; 60s polling. |
fe0/src/components/notifications/NotificationManager.tsx |
Notification list + invalidates unread count query. |
fe0/src/lib/userNotificationsApi.ts |
HTTP helper for unread count. |
fe0/src/shared/api/client.ts |
Axios instance; dev logging—§6, §9. |
fe0/src/App.tsx |
QueryClientProvider + router (actual entry today). |
fe0/src/app/App.tsx |
Alternate shell—remove as part of §7. |
15. Local machine vs cloud server (why behavior can look different)
The admin inbox polling interval is not environment-specific in code: refetchInterval: 10s runs the same in dev, local production builds, and cloud deploys. If the admin dashboard is open and focused, you should see the same intent (repeated GET /api/applications) everywhere.
What often differs is how noticeable that is.
Higher latency on the cloud
On a remote host, each poll typically spends longer in flight. While a query is in progress, TanStack Query sets isFetching === true for that query.
- Localhost: UI tied to
isFetchingmay flicker too fast to see. - Cloud: the same coupling looks like a steady “refresh” problem (§10).
Stabilizing export used export-only loading state so the button does not follow list refetch; slow networks still poll the same, but the control stays calm.
Dev vs production logging
- Local (
vite dev): success logs per response can make the console look very busy—often logging, not extra requests vs prod with the same code paths. - Cloud (typical production build): those success logs are off; use Network in DevTools to see polling.
Deployment or asset skew
If the server serves an older bundle (cached index.html/assets, wrong image, or different branch), behavior can diverge from your laptop until deploys and caches align.
Tab visibility and throttling
Browsers may throttle timers for background tabs. Testing with the dashboard tab in the background locally can make polls appear rarer than when the tab is focused. Visibility-aware polling (§8) makes behavior match operator expectations and reduces waste.
How to verify locally
Open the admin inbox, keep the tab focused, wait 15–20 seconds, and watch Network for repeating GET /api/applications (same pattern as cloud).
What to preserve from the current design
placeholderData: (previous) => previousto limit table flicker.- Invalidating
notifications-unread-countafter mutations rather than waiting for the next poll. - A single shared
apiClient—work above layers policy on top of it, not a replacement. - Documenting local-vs-cloud differences (latency, logging,
isFetching) as institutional knowledge.
Update this doc when refetchInterval / focus policies change, App entrypoints are consolidated, or admin/council refresh strategies are unified.