# 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

```mermaid
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.tsx`** chooses 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`, runs `queryFn` on mount, and can **refetch on an interval** or **when the window regains focus**, depending on per-query options and **explicit** `QueryClient.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 its `dataUpdatedAt` changes, an effect runs `queryClient.invalidateQueries({ queryKey: ["applications"] })`, which pulls a fresh `/api/applications` without 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):**

1. **Primary:** invalidation on mutations (`approve`, `reject`, `submit`, `assign`, etc.) plus invalidation on lightweight **report sync** / version signals where applicable.
2. **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.
3. **Later (product-driven only):** **SSE** behind `apiClient` if 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-count`
- `refetchInterval`: **60_000 ms** (once per minute)
- `refetchOnWindowFocus`: `true`
- `staleTime`: **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:**

1. **Sample or summarize** responses in dev; prefer `console.debug` over `console.log` for high-volume paths so DevTools defaults stay readable.
2. **Guard production** with a build-time assertion (or strict env contract), not `import.meta.env.DEV` alone.

## 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):**

1. **Pick one entrypoint.** Remove the duplicate in the **same** change set; do not leave a long-lived TODO.
2. **Prevent regression:** CI or ESLint `no-restricted-imports` forbidding the removed path if it could be revived.
3. **Set explicit `defaultOptions`** on the surviving `QueryClient`, 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):

```ts
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.

```ts
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.
- **`isFetching`** should **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:**

- **`useMemo`** for 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):

```ts
// 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):

1. **First** — **One `App.tsx`**, explicit **`QueryClient.defaultOptions`**, CI/ESLint guard against the removed path (**§7**).
2. **Next** — **`isFetching` audit**; **visibility-aware** polling helper; replace admin inbox **`"always"`** with **`true` + `staleTime`** (**§8, §10**).
3. **Then** — **Centralize `POLL_INTERVALS`**; **document and implement** timeout / retry / auth behavior (**§9, §12**); verify **query key stability** (**§11**).
4. **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 `isFetching` may **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) => previous`** to limit table flicker.
- **Invalidating `notifications-unread-count` after 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.*
