Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover.
- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash.
- **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit).
- **Auto-refresh** — Visibility-aware polling that pauses when tab is hidden.
- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs (requires notifications scope for efficient 304 change detection); hot poll pauses to save API budget.

## Tech Stack

Expand Down Expand Up @@ -55,7 +55,7 @@ src/
services/
api.ts # GitHub API methods (fetchOrgs, fetchRepos, fetchIssues, fetchPRs, fetchWorkflowRuns)
github.ts # Octokit client factory with ETag caching and rate limit tracking
poll.ts # Poll coordinator with visibility-aware auto-refresh
poll.ts # Poll coordinator with background refresh + hot poll for in-flight items
stores/
auth.ts # OAuth token management (localStorage persistence, validateToken)
cache.ts # IndexedDB cache with TTL eviction and ETag support
Expand Down
36 changes: 30 additions & 6 deletions src/app/services/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export function clearHotSets(): void {
_hotRuns.clear();
}

/** Simulate 403 on /notifications — disables the notifications gate.
* Used by tests to exercise the conditional background-poll guard. */
export function disableNotifGate(): void {
_notifGateDisabled = true;
}

export function resetPollState(): void {
_notifLastModified = null;
_lastSuccessfulFetch = null;
Expand Down Expand Up @@ -178,8 +184,8 @@ async function hasNotificationChanges(): Promise<boolean> {
) {
console.warn("[poll] Notifications API returned 403 — disabling gate");
pushNotification("notifications", config.authMethod === "pat"
? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope"
: "Notifications API returned 403 — check that the notifications scope is granted", "warning");
? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope. Background refresh in hidden tabs is disabled."
: "Notifications API returned 403 — check that the notifications scope is granted. Background refresh in hidden tabs is disabled.", "warning");
_notifGateDisabled = true;
}
return true;
Expand Down Expand Up @@ -323,8 +329,12 @@ function withJitter(intervalMs: number): number {
* - Triggers an immediate fetch on init
* - Polls at getInterval() seconds (reactive — restarts when interval changes)
* - If getInterval() === 0, disables auto-polling (SDR-017)
* - Pauses when document is hidden; resumes on visibility restore
* - Refreshes immediately on re-visible if hidden for >2 min
* - Continues polling in background tabs when notifications gate is available
* (304 responses make background polls near-zero cost). When the gate is
* disabled (fine-grained PAT or missing notifications scope), background
* polling pauses to conserve API budget.
* - On re-visible after >2 min hidden, fires catch-up fetch (safety net for
* browser tab throttling/freezing — Safari purge, Chrome Energy Saver)
* - Applies ±30 second jitter to poll interval
*
* Must be called inside a reactive root (e.g., createRoot or component body).
Expand Down Expand Up @@ -392,11 +402,20 @@ export function createPollCoordinator(

const intervalMs = withJitter(intervalSec * 1000);
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
// Without the notifications gate (403 — scope not granted), every background
// poll is a full fetch with no 304 shortcut. Skip background polls to avoid
// burning API budget; the catch-up handler still fires on tab return.
if (document.visibilityState === "hidden" && _notifGateDisabled) return;
void doFetch();
}, intervalMs);
}

// Safety net for browser-level tab throttling/freezing. Background polling
// continues via setInterval, but browsers may throttle or freeze timers in
// hidden tabs (Chrome Energy Saver, Safari tab purge, Firefox timer capping).
// When the tab becomes visible again after >2 min, this handler fires a
// catch-up fetch in case the browser suppressed scheduled polls. The
// notifications gate (304) makes redundant fetches near-zero cost.
function handleVisibilityChange(): void {
if (document.visibilityState === "hidden") {
hiddenAt = Date.now();
Expand Down Expand Up @@ -574,6 +593,9 @@ export async function fetchHotData(): Promise<{
* in-flight items without a full poll cycle. Uses setTimeout chains to avoid
* overlapping concurrent fetches.
*
* - Pauses when document is hidden (visual-only feedback has no value in background tabs)
* - Resumes on next scheduled cycle when tab becomes visible
*
* Must be called inside a SolidJS reactive root (uses createEffect + onCleanup).
*
* @param getInterval - Reactive accessor returning interval in seconds
Expand Down Expand Up @@ -621,7 +643,9 @@ export function createHotPollCoordinator(
return;
}

// Skip fetch when page is hidden
// Skip fetch when page is hidden — hot poll provides visual feedback
// (shimmer, status changes) that has no value in a background tab.
// The full poll continues in background for data freshness.
if (document.visibilityState === "hidden") {
schedule(myGeneration);
return;
Expand Down
29 changes: 29 additions & 0 deletions tests/services/hot-poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,36 @@ describe("createHotPollCoordinator", () => {
Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true, configurable: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(onHotData).not.toHaveBeenCalled();
dispose();
});
});

it("resumes fetching after hidden→visible transition", async () => {
const onHotData = vi.fn();
const requestFn = vi.fn(() => Promise.resolve({
data: { id: 1, status: "in_progress", conclusion: null, updated_at: "2026-01-01T00:00:00Z", completed_at: null },
headers: {},
}));
mockGetClient.mockReturnValue(makeOctokit(requestFn));

rebuildHotSets({
...emptyData,
workflowRuns: [makeWorkflowRun({ id: 1, status: "in_progress", conclusion: null, repoFullName: "o/r" })],
});

await createRoot(async (dispose) => {
createHotPollCoordinator(() => 10, onHotData);

// Hidden — cycle skips fetch
Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true, configurable: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(onHotData).not.toHaveBeenCalled();

// Visible — next cycle should fetch
Object.defineProperty(document, "visibilityState", { value: "visible", writable: true, configurable: true });
await vi.advanceTimersByTimeAsync(10_000);
expect(onHotData).toHaveBeenCalled();

dispose();
});
});
Expand Down
137 changes: 104 additions & 33 deletions tests/services/poll.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "fake-indexeddb/auto";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRoot } from "solid-js";
import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll";
import { createRoot, createSignal } from "solid-js";
import { createPollCoordinator, disableNotifGate, resetPollState, type DashboardData } from "../../src/app/services/poll";

// Mock pushError so we can spy on it
const mockPushError = vi.fn();
Expand Down Expand Up @@ -29,6 +29,7 @@ vi.mock("../../src/app/lib/errors", () => ({
vi.mock("../../src/app/lib/notifications", () => ({
detectNewItems: vi.fn(() => []),
dispatchNotifications: vi.fn(),
_resetNotificationState: vi.fn(),
}));

// Mock config so doFetch doesn't fail when accessing config.selectedRepos
Expand Down Expand Up @@ -119,7 +120,8 @@ describe("createPollCoordinator", () => {
});
});

it("pauses polling when document is hidden", async () => {
it("continues polling when document is hidden (notifications gate enabled)", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0
const fetchAll = makeFetchAll();

await createRoot(async (dispose) => {
Expand All @@ -131,14 +133,16 @@ describe("createPollCoordinator", () => {
// Hide document
setDocumentVisible(false);

// Advance past the interval
vi.advanceTimersByTime(90_000);
// Advance past the interval (60s with 0 jitter)
vi.advanceTimersByTime(61_000);
await Promise.resolve();

// Should not have fetched while hidden
expect(fetchAll.mock.calls.length).toBe(callsAfterInit);
// Should have fetched while hidden (background refresh)
expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit);
dispose();
});

randomSpy.mockRestore();
});

it("triggers immediate refresh on re-visible after >2 minutes hidden", async () => {
Expand All @@ -160,13 +164,41 @@ describe("createPollCoordinator", () => {
setDocumentVisible(true);
await Promise.resolve();

// Should have triggered an immediate fetch on re-visible
expect(fetchAll.mock.calls.length).toBe(callsAfterInit + 1);
// Should have triggered at least a catch-up fetch on re-visible
// (background polls may also have fired if interval < hidden duration)
expect(fetchAll.mock.calls.length).toBeGreaterThanOrEqual(callsAfterInit + 1);
dispose();
});
});

it("pauses background polling when hidden and notifications gate is disabled", async () => {
disableNotifGate();
const fetchAll = makeFetchAll();

await createRoot(async (dispose) => {
createPollCoordinator(makeGetInterval(60), fetchAll);
await Promise.resolve(); // initial fetch

const callsAfterInit = fetchAll.mock.calls.length;

// Hide document
setDocumentVisible(false);

// Advance past the interval
vi.advanceTimersByTime(90_000);
await Promise.resolve();

// Should NOT have fetched — gate disabled means no cheap 304, skip background polls
expect(fetchAll.mock.calls.length).toBe(callsAfterInit);
dispose();
});

resetPollState(); // restore gate for other tests
});

it("does NOT trigger immediate refresh on re-visible within 2 minutes", async () => {
// Pin jitter to 0 so 300s interval is exactly 300s (no background poll in 90s)
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchAll = makeFetchAll();

await createRoot(async (dispose) => {
Expand All @@ -187,6 +219,50 @@ describe("createPollCoordinator", () => {
expect(fetchAll.mock.calls.length).toBe(callsAfterInit);
dispose();
});

randomSpy.mockRestore();
});

it("resets timer on re-visible after >2 min, preventing double-fire with background polls", async () => {
// Pin jitter to 0 so 60s interval is exactly 60s
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const fetchAll = makeFetchAll();

await createRoot(async (dispose) => {
createPollCoordinator(makeGetInterval(60), fetchAll);
await Promise.resolve(); // initial fetch

const callsAfterInit = fetchAll.mock.calls.length;

// Hide for >2 min — background polls fire at 60s and 120s
setDocumentVisible(false);
vi.advanceTimersByTime(130_000);
await Promise.resolve();

const callsWhileHidden = fetchAll.mock.calls.length;
expect(callsWhileHidden).toBeGreaterThan(callsAfterInit);

// Restore visibility — catch-up fetch fires + timer resets
setDocumentVisible(true);
await Promise.resolve();

const callsAfterRevisible = fetchAll.mock.calls.length;
expect(callsAfterRevisible).toBeGreaterThan(callsWhileHidden);

// Advance 30s — should NOT fire (timer was reset to full 60s interval)
vi.advanceTimersByTime(30_000);
await Promise.resolve();
expect(fetchAll.mock.calls.length).toBe(callsAfterRevisible);

// Advance another 31s (61s from reset) — timer fires
vi.advanceTimersByTime(31_000);
await Promise.resolve();
expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterRevisible);

dispose();
});

randomSpy.mockRestore();
});

it("manual refresh triggers fetch and resets the timer", async () => {
Expand All @@ -207,40 +283,35 @@ describe("createPollCoordinator", () => {
});

it("config change (interval change) restarts the interval", async () => {
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0
const fetchAll = makeFetchAll();
let intervalSec = 300;

await createRoot(async (dispose) => {
// Use a signal-based getter to simulate reactive config
const [getInterval, setGetInterval] = (() => {
let fn = () => intervalSec;
return [
() => fn(),
(newFn: () => number) => {
fn = newFn;
},
] as const;
})();

createPollCoordinator(getInterval, fetchAll);
const [interval, setInterval] = createSignal(300);

createPollCoordinator(interval, fetchAll);
await Promise.resolve(); // initial fetch

// Simulate config change to shorter interval by providing a new accessor
// In practice SolidJS createEffect re-runs when reactive dependencies change.
// Here we verify that calling with interval=60 fires within 90s.
intervalSec = 60;
void setGetInterval; // suppress unused warning
const callsAfterInit = fetchAll.mock.calls.length;

// At 300s interval, 90s should NOT fire
vi.advanceTimersByTime(90_000);
await Promise.resolve();
expect(fetchAll.mock.calls.length).toBe(callsAfterInit);

// Change interval to 60s — createEffect re-fires, timer restarts
setInterval(60);
await Promise.resolve(); // let effect run

// Advance 61s — new 60s interval should fire
vi.advanceTimersByTime(61_000);
await Promise.resolve();
expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit);

// At 300s interval, 90s would not fire. But with 60s interval restart,
// it should fire at least once more. Since the internal createEffect
// is not re-triggered (intervalSec is not a signal), we only verify
// that the original timer was set and would eventually fire.
// The key test is just that manualRefresh + timer work correctly.
dispose();
});

randomSpy.mockRestore();
});

it("interval=0 disables auto-refresh (no setInterval)", async () => {
Expand Down Expand Up @@ -406,7 +477,7 @@ describe("createPollCoordinator", () => {
});
});

// ── qa-3: doFetch skipped path — no restore (reconciliation replaces snapshot/restore) ──
// ── qa-3a: doFetch skipped path — no restore (reconciliation replaces snapshot/restore) ──

it("skipped fetch does NOT call pushError for previous errors (no restore logic)", async () => {
mockPushError.mockClear();
Expand Down