Skip to content

Commit 2950ae5

Browse files
CoderCococlaudeCopilot
authored
feat(web): polling indicator, manual refresh, stale-data warning (#118)
Closes #65 ## Summary Centralises polling state in a shared `PollingProvider` so every primary route can see when its data was last refreshed, when the next poll is due, and when it has gone stale (2× the interval without a successful response). - New `polling/` module: - `PollingProvider` — registry of named pollers (interval + fn) with per-poller `lastSuccessAt` / `lastAttemptAt` / `loading` / `error` state, plus `refresh(name)` and `refreshAll()`. A 1Hz heartbeat drives "Xs ago" re-renders without each consumer running its own timer. Pure helper `isStale()` enforces the 2× rule. - `GameStatusProvider` — registers the dashboard's status + cost-estimate poll above the router so polling persists across `/logs`, `/costs`, `/discord`, `/settings`. Drop-in replacement for the old `useGameStatus` hook. - `PollingIndicator` — "Updated 3s ago" with a rotating refresh icon (spins while in-flight), a Radix tooltip for "next refresh in 17s", and a red "Stale · last updated 47s ago" pill that auto-clears on the next success. - `useFileManager` registers/unregisters its reactive 5-second poll with the shared registry while the helper task is `starting`, so the LIVE indicator and top-bar Refresh see it. - `AppLayout` top bar gets a Refresh button (icon spins while any poll is loading) and the LIVE dot now pulses cyan when fresh / dims when every poll is stale. - `<PollingIndicator />` placed on every primary route header (Dashboard, Costs, Discord, Logs, Settings). ## Test plan - [x] `npm run app:test` — 294/294 passing (new `PollingProvider.test.ts` covers `isStale` boundary cases; `LogsPage.test.tsx` wrapped in `PollingProvider`). - [x] `npm run app:lint` — clean. - [x] `npm run app:build` — clean (vite build). - [x] `npx playwright test --list` — 53 specs enumerate, including the new `polling.spec.ts` (5 cases: "Updated …" visible on Dashboard / Logs / Discord / Settings, plus the top-bar Refresh button triggers an extra `GET /api/status`). - [ ] Manual: open the dashboard, watch "Updated" count up over 20s, navigate between routes and confirm the indicator persists, click Refresh and confirm the icon spins. --- _Generated by [Claude Code](https://claude.ai/code/session_01ASmrh4hn4qCM7unq7T3yk5)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: CoderCoco <1407188+CoderCoco@users.noreply.github.com>
1 parent 3ef17a1 commit 2950ae5

23 files changed

Lines changed: 1398 additions & 106 deletions

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK
176176
- Tests live **next to the component** (`Foo.tsx``Foo.test.tsx`), not in a separate `__tests__` directory. Mock the API client and any module-level singletons via `vi.mock`. Use `vi.stubGlobal('EventSource', …)` for SSE-driven components (jsdom doesn't ship one).
177177
- Cover: visible rendering for each `state` branch, every callback prop firing with the right argument, internal state transitions (open/close, pause/resume), and any non-trivial pure helper. Don't reach for snapshots — they break on every Tailwind tweak. Don't repeat assertions already covered by the e2e tier (full SSE streaming flow, real network, full routing).
178178

179+
**Routed page tests (`@gsd/web`):**
180+
- Each routed page (`DashboardPage`, `CostsPage`, `DiscordPage`, `LogsPage`, `SettingsPage`) gets a co-located `*.test.tsx` that mounts the page through `renderPage()` (`app/packages/web/src/test-utils/renderPage.tsx`). The helper wraps children in `PollingProvider → GameStatusProvider → MemoryRouter` so the same provider stack the production app uses is exercised; pass `initialEntries` when the page reads `useLocation`.
181+
- Mock `../api.js` with `vi.mock` + `vi.hoisted` so the page drives off canned data instead of real fetches. Stub every method the page (and the GameStatusProvider above it) calls — at minimum `api.status` and `api.costsEstimate` — or the test will hang waiting for the polling registry to settle.
182+
- These tests are intentionally **complementary** to the e2e tier, not a replacement: the e2e specs prove the indicator + chrome render at the route level under a real Vite preview build; the unit tests pin the page's own provider wiring (e.g. that `<PollingIndicator />` resolves to "Updated …" once the mocked status poll resolves) and let us iterate on the page layout without spinning up Playwright.
183+
- Keep page-test scope tight: smoke-render each header section, exercise interactive controls that aren't already covered by a child component's unit test, and verify the polling indicator wiring. Anything that requires real HTTP belongs in the planned tier-2 specs (#75).
184+
179185
**Playwright conventions:**
180186
- Specs live under `app/packages/web/e2e/specs/`.
181187
- Shared stub helpers and fixtures are in `app/packages/web/e2e/fixtures/`.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test, expect, stubApis, STOPPED_GAME } from '../fixtures/index.js';
2+
3+
/**
4+
* Specs for the polling indicator + top-bar Refresh introduced in #65. The
5+
* shared {@link PollingProvider} runs the status poll above the router, so
6+
* every primary route should display "Updated …" and the top-bar Refresh
7+
* button should trigger a fresh `/api/status` call on demand.
8+
*/
9+
10+
test.describe('polling indicator', () => {
11+
test('should render the "Updated …" label on the dashboard', async ({ dashboard }) => {
12+
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });
13+
await dashboard.goto();
14+
15+
await expect(dashboard.page.getByText(/updated\s+\S+\s+ago/i).first()).toBeVisible();
16+
});
17+
18+
test('should keep the indicator visible after navigating to /logs', async ({ dashboard, layout }) => {
19+
// Use an empty statuses list so the GameCard's per-card Logs link doesn't
20+
// collide with the sidebar's "Logs" nav link (Playwright strict mode).
21+
await stubApis(dashboard.page, { statuses: [] });
22+
await dashboard.goto();
23+
await expect(dashboard.page.getByText(/updated\s+\S+\s+ago/i).first()).toBeVisible();
24+
25+
await layout.navigateTo('Logs', '/logs');
26+
await expect(dashboard.page.getByText(/updated\s+\S+\s+ago/i).first()).toBeVisible();
27+
});
28+
29+
test('should keep the indicator visible after navigating to /discord', async ({ dashboard, layout }) => {
30+
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });
31+
await dashboard.goto();
32+
33+
await layout.navigateTo('Discord', '/discord');
34+
await expect(dashboard.page.getByText(/updated\s+\S+\s+ago/i).first()).toBeVisible();
35+
});
36+
37+
test('should keep the indicator visible after navigating to /settings', async ({ dashboard, layout }) => {
38+
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });
39+
await dashboard.goto();
40+
41+
await layout.navigateTo('Settings', '/settings');
42+
await expect(dashboard.page.getByText(/updated\s+\S+\s+ago/i).first()).toBeVisible();
43+
});
44+
});
45+
46+
test.describe('top-bar refresh', () => {
47+
test('should re-fetch /api/status when the Refresh button is clicked', async ({ dashboard }) => {
48+
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });
49+
50+
// stubApis already registered a catch-all for /api/status; this later
51+
// registration wins (Playwright matches routes in REVERSE order) and lets
52+
// us count GETs against the endpoint.
53+
let statusGetCount = 0;
54+
await dashboard.page.route('**/api/status', (route) => {
55+
if (route.request().method() === 'GET') statusGetCount += 1;
56+
return route.fulfill({ json: [STOPPED_GAME] });
57+
});
58+
59+
await dashboard.goto();
60+
// Wait for the initial mount + first poll to settle so we can compare.
61+
await expect(dashboard.statusBadge('STOPPED')).toBeVisible();
62+
const before = statusGetCount;
63+
64+
await dashboard.page.getByRole('button', { name: 'Refresh all' }).click();
65+
66+
await expect.poll(() => statusGetCount).toBeGreaterThan(before);
67+
});
68+
});

app/packages/web/src/App.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { CostsPage } from './pages/CostsPage.js';
88
import { DiscordPage } from './pages/DiscordPage.js';
99
import { LogsPage } from './pages/LogsPage.js';
1010
import { SettingsPage } from './pages/SettingsPage.js';
11+
import { PollingProvider } from './polling/PollingProvider.js';
12+
import { GameStatusProvider } from './polling/GameStatusProvider.js';
1113

1214
/**
1315
* Root component. Wires up the 401 handler on `api.ts` and renders the routed
@@ -31,17 +33,21 @@ export default function App() {
3133
}, []);
3234

3335
return (
34-
<BrowserRouter>
35-
<ApiTokenModal open={needsToken} onSuccess={() => setNeedsToken(false)} />
36-
<AppLayout>
37-
<Routes>
38-
<Route path="/" element={<DashboardPage />} />
39-
<Route path="/costs" element={<CostsPage />} />
40-
<Route path="/discord" element={<DiscordPage />} />
41-
<Route path="/logs" element={<LogsPage />} />
42-
<Route path="/settings" element={<SettingsPage />} />
43-
</Routes>
44-
</AppLayout>
45-
</BrowserRouter>
36+
<PollingProvider>
37+
<GameStatusProvider>
38+
<BrowserRouter>
39+
<ApiTokenModal open={needsToken} onSuccess={() => setNeedsToken(false)} />
40+
<AppLayout>
41+
<Routes>
42+
<Route path="/" element={<DashboardPage />} />
43+
<Route path="/costs" element={<CostsPage />} />
44+
<Route path="/discord" element={<DiscordPage />} />
45+
<Route path="/logs" element={<LogsPage />} />
46+
<Route path="/settings" element={<SettingsPage />} />
47+
</Routes>
48+
</AppLayout>
49+
</BrowserRouter>
50+
</GameStatusProvider>
51+
</PollingProvider>
4652
);
4753
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useEffect } from 'react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import { act, render, screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import { LiveIndicator, RefreshAllButton } from './AppLayout.js';
6+
import { PollingProvider, usePollingActions } from '../polling/PollingProvider.js';
7+
8+
/**
9+
* Mounts a child component that registers a poller in `useEffect` so the
10+
* surrounding render captures the resulting registry entry without
11+
* triggering a setState-during-render warning.
12+
*/
13+
function MountPoller({
14+
name,
15+
intervalMs,
16+
fn,
17+
}: {
18+
name: string;
19+
intervalMs: number;
20+
fn: () => Promise<void>;
21+
}) {
22+
const { register } = usePollingActions();
23+
useEffect(() => register(name, fn, intervalMs), [register, name, intervalMs, fn]);
24+
return null;
25+
}
26+
27+
describe('AppLayout — RefreshAllButton', () => {
28+
it('should be disabled when the polling registry is empty', () => {
29+
render(
30+
<PollingProvider>
31+
<RefreshAllButton />
32+
</PollingProvider>,
33+
);
34+
35+
expect(screen.getByRole('button', { name: 'Refresh all' })).toBeDisabled();
36+
});
37+
38+
it('should fire every registered poller when clicked', async () => {
39+
const fn = vi.fn().mockResolvedValue(undefined);
40+
const user = userEvent.setup();
41+
42+
render(
43+
<PollingProvider>
44+
<MountPoller name="status" intervalMs={20_000} fn={fn} />
45+
<RefreshAllButton />
46+
</PollingProvider>,
47+
);
48+
49+
// Let the registration's automatic first run complete so we can compare.
50+
await act(async () => {
51+
await Promise.resolve();
52+
await Promise.resolve();
53+
});
54+
const before = fn.mock.calls.length;
55+
56+
await user.click(screen.getByRole('button', { name: 'Refresh all' }));
57+
58+
await act(async () => {
59+
await Promise.resolve();
60+
});
61+
expect(fn.mock.calls.length).toBeGreaterThan(before);
62+
});
63+
});
64+
65+
describe('AppLayout — LiveIndicator', () => {
66+
it('should always render the LIVE label so the chrome is visible from first paint', () => {
67+
render(
68+
<PollingProvider>
69+
<LiveIndicator />
70+
</PollingProvider>,
71+
);
72+
73+
expect(screen.getByText('LIVE')).toBeInTheDocument();
74+
});
75+
76+
it('should show a pulsing cyan dot once a registered poller has reported success', async () => {
77+
const fn = vi.fn().mockResolvedValue(undefined);
78+
const { container } = render(
79+
<PollingProvider>
80+
<MountPoller name="status" intervalMs={20_000} fn={fn} />
81+
<LiveIndicator />
82+
</PollingProvider>,
83+
);
84+
85+
await act(async () => {
86+
await Promise.resolve();
87+
await Promise.resolve();
88+
});
89+
90+
const dot = container.querySelector('div.rounded-full');
91+
expect(dot?.className).toMatch(/animate-pulse/);
92+
expect(dot?.className).toMatch(/var\(--color-cyan\)/);
93+
});
94+
});

app/packages/web/src/components/AppLayout.tsx

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { ReactNode, useEffect, useState } from 'react';
22
import { Link, useLocation } from 'react-router-dom';
33
import { api, type EnvInfo } from '../api.js';
44
import { cn } from '../lib/utils.js';
5+
import { Button } from '@/components/ui/button';
6+
import { isStale, usePollingActions, usePollingState } from '../polling/PollingProvider.js';
57
import {
68
LayoutDashboard,
79
Server,
@@ -10,6 +12,7 @@ import {
1012
Bell,
1113
MessageSquare,
1214
Settings,
15+
RefreshCw,
1316
} from 'lucide-react';
1417

1518
interface NavItem {
@@ -114,11 +117,8 @@ export function AppLayout({ children }: { children: ReactNode }) {
114117
/>
115118
</div>
116119

117-
{/* LIVE indicator */}
118-
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded border border-border">
119-
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
120-
<span className="text-xs font-medium text-muted-foreground">LIVE</span>
121-
</div>
120+
<RefreshAllButton />
121+
<LiveIndicator />
122122

123123
{/* Avatar placeholder */}
124124
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center">
@@ -136,6 +136,57 @@ export function AppLayout({ children }: { children: ReactNode }) {
136136
);
137137
}
138138

139+
/**
140+
* Top-bar Refresh button — triggers every active poller in the registry. The
141+
* icon spins while at least one poll is in flight so the operator gets a brief
142+
* loading affordance even if the underlying call returns instantly.
143+
*/
144+
export function RefreshAllButton() {
145+
const { refreshAll } = usePollingActions();
146+
const { pollers } = usePollingState();
147+
const anyLoading = Object.values(pollers).some((p) => p.loading);
148+
return (
149+
<Button
150+
variant="secondary"
151+
size="sm"
152+
onClick={() => void refreshAll()}
153+
aria-label="Refresh all"
154+
disabled={Object.keys(pollers).length === 0}
155+
>
156+
<RefreshCw className={cn('size-3.5', anyLoading && 'animate-spin')} />
157+
Refresh
158+
</Button>
159+
);
160+
}
161+
162+
/**
163+
* Top-bar LIVE indicator — pulses cyan while at least one poller has a fresh
164+
* success, dims gray when every poller is past 2× its interval, and goes
165+
* neutral when no pollers are registered yet.
166+
*/
167+
export function LiveIndicator() {
168+
const { pollers, tick } = usePollingState();
169+
void tick;
170+
const now = Date.now();
171+
const entries = Object.values(pollers);
172+
const anyFresh = entries.some((p) => p.lastSuccessAt !== null && !isStale(p, now));
173+
const allStale = entries.length > 0 && entries.every((p) => isStale(p, now));
174+
const dotClass = anyFresh
175+
? 'bg-[var(--color-cyan)] animate-pulse'
176+
: allStale
177+
? 'bg-[var(--color-muted-foreground)]/60'
178+
: 'bg-[var(--color-muted-foreground)]/40';
179+
const labelClass = allStale
180+
? 'text-[var(--color-muted-foreground)]/60'
181+
: 'text-muted-foreground';
182+
return (
183+
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded border border-border">
184+
<div className={cn('w-2 h-2 rounded-full', dotClass)} />
185+
<span className={cn('text-xs font-medium', labelClass)}>LIVE</span>
186+
</div>
187+
);
188+
}
189+
139190
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
140191
const Icon = item.icon;
141192
const className = cn(

0 commit comments

Comments
 (0)