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
24 changes: 23 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi
- [Upstream Repos](#upstream-repos)
- [Refresh and Polling](#refresh-and-polling)
- [Notifications](#notifications)
- [Tracked Items](#tracked-items)
- [Repo Pinning](#repo-pinning)
- [Settings Reference](#settings-reference)
- [Troubleshooting](#troubleshooting)
Expand Down Expand Up @@ -71,13 +72,14 @@ OAuth sign-in uses your existing GitHub org memberships. If a private organizati

### Tab Structure

The dashboard has three tabs:
The dashboard has three tabs by default, with an optional fourth:

| Tab | Contents |
|-----|----------|
| **Issues** | Open issues across your selected repos where you are the author, assignee, or mentioned |
| **Pull Requests** | Open PRs where you are the author, reviewer, or assignee |
| **Actions** | Recent workflow runs for your selected repos |
| **Tracked** | Manually pinned issues and PRs (opt-in via Settings) |

The active tab is remembered across page loads by default. You can set a fixed default tab in Settings.

Expand Down Expand Up @@ -325,6 +327,24 @@ Per-type toggles (all default to on when notifications are enabled):

---

## Tracked Items

The Tracked tab lets you pin issues and PRs into a personal TODO list that you can manually reorder by priority.

**Enabling:** Go to **Settings > Tabs** and toggle **Enable tracked items**. A fourth **Tracked** tab appears on the dashboard.

**Pinning items:** On the Issues and Pull Requests tabs, hover over any row to reveal a bookmark icon. Click it to pin the item to your tracked list. Click it again to unpin. The bookmark appears filled and highlighted on tracked items.

**Tracked tab:** Shows your pinned items in a flat list (not grouped by repo). Each item displays a type badge (Issue or PR) and uses live data from the poll cycle — statuses, check results, and labels stay current. Items whose repo is no longer being polled show a minimal fallback row with stored metadata.

**Reordering:** Use the chevron buttons on the left side of each row to move items up or down. Items slide smoothly into their new position.

**Auto-removal:** When a tracked issue is closed or a tracked PR is merged, it is automatically removed from the list. Closure is detected by absence from the `is:open` poll results. For PRs detected as closed by the hot poll, removal happens within seconds.

**Relationship to other features:** The Tracked tab bypasses the org/repo filter — it always shows all your pinned items regardless of which repo filter is active. Ignoring an item from the Issues or Pull Requests tab also removes it from the tracked list. The tracked list is preserved when tracking is disabled and restored when re-enabled.

---

## Repo Pinning

Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list within its tab regardless of sort order or how recently it was updated.
Expand Down Expand Up @@ -358,6 +378,7 @@ Settings are saved automatically to `localStorage` and persist across sessions.
| Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. |
| Default tab | Issues | Tab shown when opening the dashboard fresh (without remembered last tab). |
| Remember last tab | On | Return to the last active tab on revisit. |
| Enable tracked items | Off | Show the Tracked tab for pinning issues and PRs to a personal TODO list. |

### View State Settings

Expand All @@ -371,6 +392,7 @@ These are UI preferences that persist across sessions but are not included in th
| Hide Dependency Dashboard | On | Whether to hide the Renovate Dependency Dashboard issue. |
| Sort preferences | Updated (desc) | Sort field and direction per tab, remembered across sessions. |
| Pinned repos | (none) | Repos pinned to the top of each tab's list. |
| Tracked items | (none) | Issues and PRs pinned to the Tracked tab (max 200). |

---

Expand Down
7 changes: 4 additions & 3 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Page } from "@playwright/test";
* OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed.
* The app calls validateToken() on load, which GETs /user to verify the token.
*/
export async function setupAuth(page: Page) {
export async function setupAuth(page: Page, configOverrides?: Record<string, unknown>) {
// Catch-all: abort any unmocked GitHub API request so failures are loud
await page.route("https://api.github.com/**", (route) => route.abort());

Expand Down Expand Up @@ -46,15 +46,16 @@ export async function setupAuth(page: Page) {
);

// Seed localStorage with auth token and config before the page loads
await page.addInitScript(() => {
await page.addInitScript((overrides) => {
localStorage.setItem("github-tracker:auth-token", "fake-token");
localStorage.setItem(
"github-tracker:config",
JSON.stringify({
selectedOrgs: ["testorg"],
selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }],
onboardingComplete: true,
...overrides,
})
);
});
}, configOverrides ?? {});
}
71 changes: 71 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,74 @@ test("unknown path redirects to dashboard when authenticated", async ({ page })
// catch-all → Navigate "/" → RootRedirect → validateToken() succeeds → Navigate "/dashboard"
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
});

// ── Tracked items ─────────────────────────────────────────────────────────────

test("tracked items tab appears when enabled", async ({ page }) => {
// setupAuth FIRST — registers catch-all and default GraphQL handler
await setupAuth(page, { enableTracking: true });
// Override GraphQL AFTER setupAuth — Playwright matches routes LIFO, so this wins
await page.route("https://api.github.com/graphql", (route) =>
route.fulfill({
status: 200,
json: {
data: {
issues: {
issueCount: 1,
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{
__typename: "Issue",
databaseId: 1001,
number: 42,
title: "Test tracked issue",
state: "OPEN",
url: "https://github.com/testorg/testrepo/issues/42",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
author: { login: "testuser" },
labels: { nodes: [] },
assignees: { nodes: [] },
comments: { totalCount: 0 },
repository: { nameWithOwner: "testorg/testrepo", stargazerCount: 5 },
}],
},
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
},
},
})
);
await page.goto("/dashboard");

// Verify Tracked tab is visible
await expect(page.getByRole("tab", { name: /tracked/i })).toBeVisible();

// Wait for issue data to render, then expand the repo group
await page.getByText("testorg/testrepo").click();
await expect(page.getByText("Test tracked issue")).toBeVisible();

// Hover the row's group container to reveal the pin button (opacity-0 → group-hover:opacity-100)
const row = page.locator(".group").filter({ hasText: "Test tracked issue" });
await row.hover();
const pinBtn = page.getByRole("button", { name: /^Pin #42/i });
await expect(pinBtn).toBeVisible();
await pinBtn.click();

// Switch to Tracked tab
await page.getByRole("tab", { name: /tracked/i }).click();

// Verify the pinned item appears in the Tracked tab
await expect(page.getByText("Test tracked issue")).toBeVisible();
});

test("tracked items tab hidden when disabled", async ({ page }) => {
await setupAuth(page);
await page.goto("/dashboard");

// Verify Tracked tab is NOT visible
await expect(page.getByRole("tab", { name: /tracked/i })).toHaveCount(0);

// Verify no pin buttons exist
await expect(page.locator("[aria-label^='Pin']")).toHaveCount(0);
});
4 changes: 2 additions & 2 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export default function ActionsTab(props: ActionsTabProps) {

function handleIgnore(run: WorkflowRun) {
ignoreItem({
id: String(run.id),
id: run.id,
type: "workflowRun",
repo: run.repoFullName,
title: run.name,
Expand All @@ -174,7 +174,7 @@ export default function ActionsTab(props: ActionsTabProps) {
const eventFilter = viewState.tabFilters.actions.event;

return props.workflowRuns.filter((run) => {
if (ignoredIds.has(String(run.id))) return false;
if (ignoredIds.has(run.id)) return false;
if (!viewState.showPrRuns && run.isPrRun) return false;
if (org && !run.repoFullName.startsWith(`${org}/`)) return false;
if (repo && run.repoFullName !== repo) return false;
Expand Down
85 changes: 73 additions & 12 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { createSignal, createMemo, Show, Switch, Match, onMount, onCleanup } from "solid-js";
import { createSignal, createMemo, createEffect, Show, Switch, Match, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store";
import Header from "../layout/Header";
import TabBar, { TabId } from "../layout/TabBar";
import FilterBar from "../layout/FilterBar";
import ActionsTab from "./ActionsTab";
import IssuesTab from "./IssuesTab";
import PullRequestsTab from "./PullRequestsTab";
import TrackedTab from "./TrackedTab";
import PersonalSummaryStrip from "./PersonalSummaryStrip";
import { config, setConfig, type TrackedUser } from "../../stores/config";
import { viewState, updateViewState, setSortPreference } from "../../stores/view";
import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems } from "../../stores/view";
import type { SortOption } from "../shared/SortDropdown";
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
import { fetchOrgs } from "../../services/api";
Expand Down Expand Up @@ -93,9 +94,13 @@ function resetDashboardData(): void {
localStorage.removeItem?.(DASHBOARD_STORAGE_KEY);
}

const [hasFetchedFresh, setHasFetchedFresh] = createSignal(false);
export function _resetHasFetchedFresh(value = false) { setHasFetchedFresh(value); }

// Clear dashboard data and stop polling on logout to prevent cross-user data leakage
onAuthCleared(() => {
resetDashboardData();
setHasFetchedFresh(false);
const coord = _coordinator();
if (coord) {
coord.destroy();
Expand Down Expand Up @@ -138,6 +143,7 @@ async function pollFetch(): Promise<DashboardData> {
});
// When notifications gate says nothing changed, keep existing data
if (!data.skipped) {
setHasFetchedFresh(true);
const now = new Date();

if (phaseOneFired) {
Expand Down Expand Up @@ -243,14 +249,13 @@ export default function DashboardPage() {
const [hotPollingPRIds, setHotPollingPRIds] = createSignal<ReadonlySet<number>>(new Set());
const [hotPollingRunIds, setHotPollingRunIds] = createSignal<ReadonlySet<number>>(new Set());

const initialTab = createMemo<TabId>(() => {
if (config.rememberLastTab) {
return viewState.lastActiveTab;
}
return config.defaultTab;
});
function resolveInitialTab(): TabId {
const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab;
if (tab === "tracked" && !config.enableTracking) return "issues";
return tab;
}

const [activeTab, setActiveTab] = createSignal<TabId>(initialTab());
const [activeTab, setActiveTab] = createSignal<TabId>(resolveInitialTab());

function handleTabChange(tab: TabId) {
setActiveTab(tab);
Expand All @@ -259,6 +264,37 @@ export default function DashboardPage() {

const [clockTick, setClockTick] = createSignal(0);

// Redirect away from tracked tab when tracking is disabled at runtime
createEffect(() => {
if (!config.enableTracking && activeTab() === "tracked") {
handleTabChange("issues");
}
});

// Auto-prune tracked items that are closed/merged (absent from is:open results)
createEffect(() => {
// IMPORTANT: Access reactive store fields BEFORE early-return guards
// so SolidJS registers them as dependencies even when the guard short-circuits
const issues = dashboardData.issues;
const prs = dashboardData.pullRequests;
if (!config.enableTracking || viewState.trackedItems.length === 0 || !hasFetchedFresh()) return;

const polledRepos = new Set([
...config.selectedRepos.map((r) => r.fullName),
...config.upstreamRepos.map((r) => r.fullName),
]);
const liveIssueIds = new Set(issues.map((i) => i.id));
const livePrIds = new Set(prs.map((p) => p.id));

const pruneKeys = new Set<string>();
for (const item of viewState.trackedItems) {
if (!polledRepos.has(item.repoFullName)) continue; // repo deselected — keep item
const isLive = item.type === "issue" ? liveIssueIds.has(item.id) : livePrIds.has(item.id);
if (!isLive) pruneKeys.add(`${item.type}:${item.id}`);
}
if (pruneKeys.size > 0) pruneClosedTrackedItems(pruneKeys);
});

onMount(() => {
if (!_coordinator()) {
_setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch));
Expand Down Expand Up @@ -292,6 +328,22 @@ export default function DashboardPage() {
run.completedAt = update.completedAt;
}
}));
// Prune tracked PRs that became closed/merged via hot poll.
// The auto-prune createEffect only fires when the pullRequests array
// reference changes (full refresh). Hot poll mutates nested pr.state
// in-place via produce(), leaving the array ref unchanged.
if (config.enableTracking && viewState.trackedItems.length > 0 && prUpdates.size > 0) {
const pruneKeys = new Set<string>();
for (const [prId, update] of prUpdates) {
const stateVal = update.state?.toUpperCase();
if (stateVal === "CLOSED" || stateVal === "MERGED") {
if (viewState.trackedItems.some(t => t.type === "pullRequest" && t.id === prId)) {
pruneKeys.add(`pullRequest:${prId}`);
}
}
}
if (pruneKeys.size > 0) pruneClosedTrackedItems(pruneKeys);
}
},
{
onStart: (prDbIds, runIds) => {
Expand Down Expand Up @@ -353,25 +405,26 @@ export default function DashboardPage() {

return {
issues: dashboardData.issues.filter((i) => {
if (ignoredIssues.has(String(i.id))) return false;
if (ignoredIssues.has(i.id)) return false;
if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") return false;
if (repo && i.repoFullName !== repo) return false;
if (org && !i.repoFullName.startsWith(org + "/")) return false;
return true;
}).length,
pullRequests: dashboardData.pullRequests.filter((p) => {
if (ignoredPRs.has(String(p.id))) return false;
if (ignoredPRs.has(p.id)) return false;
if (repo && p.repoFullName !== repo) return false;
if (org && !p.repoFullName.startsWith(org + "/")) return false;
return true;
}).length,
actions: dashboardData.workflowRuns.filter((w) => {
if (ignoredRuns.has(String(w.id))) return false;
if (ignoredRuns.has(w.id)) return false;
if (!viewState.showPrRuns && w.isPrRun) return false;
if (repo && w.repoFullName !== repo) return false;
if (org && !w.repoFullName.startsWith(org + "/")) return false;
return true;
}).length,
...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}),
};
});

Expand Down Expand Up @@ -404,6 +457,7 @@ export default function DashboardPage() {
activeTab={activeTab()}
onTabChange={handleTabChange}
counts={tabCounts()}
enableTracking={config.enableTracking}
/>

<FilterBar
Expand Down Expand Up @@ -442,6 +496,13 @@ export default function DashboardPage() {
refreshTick={refreshTick()}
/>
</Match>
<Match when={activeTab() === "tracked"}>
<TrackedTab
issues={dashboardData.issues}
pullRequests={dashboardData.pullRequests}
refreshTick={refreshTick()}
/>
</Match>
<Match when={activeTab() === "actions"}>
<ActionsTab
workflowRuns={dashboardData.workflowRuns}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/dashboard/IgnoreBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Tooltip } from "../shared/Tooltip";

interface IgnoreBadgeProps {
items: IgnoredItem[];
onUnignore: (id: string) => void;
onUnignore: (id: number) => void;
}

function typeIcon(type: IgnoredItem["type"]): string {
Expand Down
Loading