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
10 changes: 9 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,21 @@ The hot poll pauses automatically when the browser tab is hidden (since visual f

Hover the rate limit display in the dashboard footer to see detailed remaining counts for the Core and GraphQL API pools, plus the reset time.

### Events Poll

A lightweight poll runs every **60 seconds** to detect new activity across all your tracked repos. Instead of re-fetching all issues, PRs, and workflow runs, it checks your recent GitHub events (pushes, PR updates, issue comments, etc.) and compares event IDs against the last seen ID.

When new events are detected, the dashboard immediately runs **targeted refreshes** for only the affected repos — so a push to `my-org/api-server` refreshes just that repo's data, not everything.

The events poll uses about **1 REST request per cycle** (~1% of your hourly rate-limit budget). If you have a high volume of recent activity (100+ events per cycle), it automatically fetches up to 3 pages to avoid missing older events.

### Tab Visibility Behavior

When the tab is hidden:

- The **hot poll always pauses** (it provides only visual feedback).
- The **full refresh pauses** in background tabs — GraphQL requests have no 304 shortcut and every poll consumes real rate-limit budget.
- The **events poll continues in background** — it uses ETag conditional requests (`If-None-Match`) that return 304 when nothing has changed, costing zero rate-limit points. When changes are detected, targeted per-repo refreshes run immediately.
- The **events poll continues in background** — it is lightweight enough to run even when the tab is hidden, giving you near-real-time change detection without the cost of a full refresh.

When you return to a tab that has been hidden for more than 2 minutes, a catch-up full refresh fires immediately regardless of where the timer is in its cycle.

Expand Down
85 changes: 53 additions & 32 deletions src/app/services/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ export const ACTIONABLE_EVENT_TYPES = [
"PushEvent",
] as const;

// ── Module-level ETag state ───────────────────────────────────────────────────
// ── Module-level state ───────────────────────────────────────────────────────

function eventIdNum(id: string): number {
const n = parseInt(id, 10);
return Number.isNaN(n) ? 0 : n;
}

let _eventsETag: string | null = null;
let _lastEventId: string | null = null;

// ── Auth cleanup ──────────────────────────────────────────────────────────────

export function resetEventsState(): void {
_eventsETag = null;
_lastEventId = null;
}

Expand All @@ -60,58 +63,76 @@ export async function fetchUserEvents(
return { events: [], changed: false };
}

const headers: Record<string, string> = {};
if (_eventsETag) {
headers["If-None-Match"] = _eventsETag;
}

try {
// GitHub docs suggest per_page is capped at 30 for Events API, but empirical
// testing (2026-05-03) confirmed per_page: 100 returns 100 events successfully.
const response = await octokit.request("GET /users/{username}/events", {
username,
per_page: 100,
headers,
});

// Store ETag for next conditional request
const etag = (response.headers as Record<string, string>)["etag"];
if (etag) {
_eventsETag = etag;
let allEvents = (response.data as GitHubEvent[]);

// Paginate if page is full — older events on subsequent pages would be
// permanently missed since _lastEventId advances past them.
let page = 2;
let lastPageEvents = allEvents;
while (lastPageEvents.length === 100 && page <= 3) {
if (_lastEventId !== null) {
const threshold = eventIdNum(_lastEventId);
if (lastPageEvents.some((e) => eventIdNum(e.id) <= threshold)) break;
}
try {
const next = await octokit.request("GET /users/{username}/events", {
username,
per_page: 100,
page,
});
const nextEvents = (next.data as GitHubEvent[]);
if (nextEvents.length === 0) break;
allEvents = [...allEvents, ...nextEvents];
lastPageEvents = nextEvents;
page++;
} catch (err) {
console.warn(`[events] pagination error on page ${page}:`, err instanceof Error ? err.message : String(err));
break;
}
}

const allEvents = (response.data as GitHubEvent[]);
const maxId = allEvents.reduce(
(max, e) => Math.max(max, eventIdNum(e.id)),
0,
);

// First call: no ID filter — seed _lastEventId and return all events
// First call: seed _lastEventId and return all events
if (_lastEventId === null) {
if (allEvents.length > 0) {
_lastEventId = allEvents[0].id; // events are newest-first
if (maxId > 0) {
_lastEventId = String(maxId);
}
return { events: allEvents, changed: allEvents.length > 0 };
}

// Subsequent calls: filter to only events newer than _lastEventId
// Use numeric comparison — event IDs are numeric strings; lexicographic
// comparison would break for IDs of different lengths (e.g. "9" > "10").
const lastIdNum = parseInt(_lastEventId, 10);
const lastIdNum = eventIdNum(_lastEventId);
const newEvents = allEvents.filter(
(e) => parseInt(e.id, 10) > lastIdNum,
(e) => eventIdNum(e.id) > lastIdNum,
);

if (newEvents.length > 0) {
_lastEventId = allEvents[0].id; // newest event is always first
if (maxId > lastIdNum) {
_lastEventId = String(maxId);
}

return { events: newEvents, changed: newEvents.length > 0 };
} catch (err) {
// Octokit throws RequestError on 304 — same pattern as hasNotificationChanges()
if (
typeof err === "object" &&
err !== null &&
(err as { status?: number }).status === 304
) {
return { events: [], changed: false };
const status =
err && typeof err === "object" && "status" in err
? (err as { status: number }).status
: null;
if (status === 304) {
console.warn("[events] unexpected 304 from proxy/CDN; events suppressed this cycle");
} else {
console.warn("[events] fetchUserEvents error:", err instanceof Error ? err.message : String(err));
}
// Silent fallback for all other errors — full refresh handles reconciliation
console.warn("[events] fetchUserEvents error:", err instanceof Error ? err.message : String(err));
return { events: [], changed: false };
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/app/services/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,7 +866,9 @@ export function createEventsPollCoordinator(
schedule(myGeneration, EVENTS_POLL_INTERVAL_MS);
}

// First cycle fires immediately (delay=0) to establish ETag baseline
// First cycle fires immediately (delay=0) to seed _lastEventId baseline.
// May trigger a targeted refresh if tracked repos have recent events — the
// isFullRefreshing() guard prevents doubling up with the initial full poll.
const gen = chainGeneration;
timeoutId = setTimeout(() => void cycle(gen), 0);

Expand Down
Loading