Skip to content

Commit 31df2d1

Browse files
authored
feat(ui): add tracked items tab with pin, reorder, and auto-prune (#50)
* feat(ui): adds tracked items tab with pin, reorder, and auto-prune * fix(dashboard): converts hasFetchedFresh to createSignal * fix(ui): addresses PR review findings for tracked items feature * fix(ui): addresses PR review findings for tracked items - removes ignore action from TrackedTab (untrack only) - normalizes IgnoredItem.id from string to number (z.coerce.number) - extracts TypeBadge from duplicated Switch/Match blocks - replaces Unicode arrows with Heroicons SVG chevrons - adds FLIP reorder animation (200ms ease-in-out slide) - adds hot poll prune for closed/merged tracked PRs - flattens SettingsPage toggle handler - adds config store reset in DashboardPage test beforeEach - adds tracked tab badge count integration test - adds skipped-then-real poll auto-prune test * docs: adds tracked items section to user guide
1 parent 292090f commit 31df2d1

30 files changed

+1704
-117
lines changed

docs/USER_GUIDE.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi
3232
- [Upstream Repos](#upstream-repos)
3333
- [Refresh and Polling](#refresh-and-polling)
3434
- [Notifications](#notifications)
35+
- [Tracked Items](#tracked-items)
3536
- [Repo Pinning](#repo-pinning)
3637
- [Settings Reference](#settings-reference)
3738
- [Troubleshooting](#troubleshooting)
@@ -71,13 +72,14 @@ OAuth sign-in uses your existing GitHub org memberships. If a private organizati
7172

7273
### Tab Structure
7374

74-
The dashboard has three tabs:
75+
The dashboard has three tabs by default, with an optional fourth:
7576

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

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

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

326328
---
327329

330+
## Tracked Items
331+
332+
The Tracked tab lets you pin issues and PRs into a personal TODO list that you can manually reorder by priority.
333+
334+
**Enabling:** Go to **Settings > Tabs** and toggle **Enable tracked items**. A fourth **Tracked** tab appears on the dashboard.
335+
336+
**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.
337+
338+
**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.
339+
340+
**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.
341+
342+
**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.
343+
344+
**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.
345+
346+
---
347+
328348
## Repo Pinning
329349

330350
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.
@@ -358,6 +378,7 @@ Settings are saved automatically to `localStorage` and persist across sessions.
358378
| Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. |
359379
| Default tab | Issues | Tab shown when opening the dashboard fresh (without remembered last tab). |
360380
| Remember last tab | On | Return to the last active tab on revisit. |
381+
| Enable tracked items | Off | Show the Tracked tab for pinning issues and PRs to a personal TODO list. |
361382

362383
### View State Settings
363384

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

375397
---
376398

e2e/helpers.ts

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

@@ -46,15 +46,16 @@ export async function setupAuth(page: Page) {
4646
);
4747

4848
// Seed localStorage with auth token and config before the page loads
49-
await page.addInitScript(() => {
49+
await page.addInitScript((overrides) => {
5050
localStorage.setItem("github-tracker:auth-token", "fake-token");
5151
localStorage.setItem(
5252
"github-tracker:config",
5353
JSON.stringify({
5454
selectedOrgs: ["testorg"],
5555
selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }],
5656
onboardingComplete: true,
57+
...overrides,
5758
})
5859
);
59-
});
60+
}, configOverrides ?? {});
6061
}

e2e/smoke.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,74 @@ test("unknown path redirects to dashboard when authenticated", async ({ page })
188188
// catch-all → Navigate "/" → RootRedirect → validateToken() succeeds → Navigate "/dashboard"
189189
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
190190
});
191+
192+
// ── Tracked items ─────────────────────────────────────────────────────────────
193+
194+
test("tracked items tab appears when enabled", async ({ page }) => {
195+
// setupAuth FIRST — registers catch-all and default GraphQL handler
196+
await setupAuth(page, { enableTracking: true });
197+
// Override GraphQL AFTER setupAuth — Playwright matches routes LIFO, so this wins
198+
await page.route("https://api.github.com/graphql", (route) =>
199+
route.fulfill({
200+
status: 200,
201+
json: {
202+
data: {
203+
issues: {
204+
issueCount: 1,
205+
pageInfo: { hasNextPage: false, endCursor: null },
206+
nodes: [{
207+
__typename: "Issue",
208+
databaseId: 1001,
209+
number: 42,
210+
title: "Test tracked issue",
211+
state: "OPEN",
212+
url: "https://github.com/testorg/testrepo/issues/42",
213+
createdAt: "2026-01-01T00:00:00Z",
214+
updatedAt: "2026-01-02T00:00:00Z",
215+
author: { login: "testuser" },
216+
labels: { nodes: [] },
217+
assignees: { nodes: [] },
218+
comments: { totalCount: 0 },
219+
repository: { nameWithOwner: "testorg/testrepo", stargazerCount: 5 },
220+
}],
221+
},
222+
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
223+
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
224+
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
225+
},
226+
},
227+
})
228+
);
229+
await page.goto("/dashboard");
230+
231+
// Verify Tracked tab is visible
232+
await expect(page.getByRole("tab", { name: /tracked/i })).toBeVisible();
233+
234+
// Wait for issue data to render, then expand the repo group
235+
await page.getByText("testorg/testrepo").click();
236+
await expect(page.getByText("Test tracked issue")).toBeVisible();
237+
238+
// Hover the row's group container to reveal the pin button (opacity-0 → group-hover:opacity-100)
239+
const row = page.locator(".group").filter({ hasText: "Test tracked issue" });
240+
await row.hover();
241+
const pinBtn = page.getByRole("button", { name: /^Pin #42/i });
242+
await expect(pinBtn).toBeVisible();
243+
await pinBtn.click();
244+
245+
// Switch to Tracked tab
246+
await page.getByRole("tab", { name: /tracked/i }).click();
247+
248+
// Verify the pinned item appears in the Tracked tab
249+
await expect(page.getByText("Test tracked issue")).toBeVisible();
250+
});
251+
252+
test("tracked items tab hidden when disabled", async ({ page }) => {
253+
await setupAuth(page);
254+
await page.goto("/dashboard");
255+
256+
// Verify Tracked tab is NOT visible
257+
await expect(page.getByRole("tab", { name: /tracked/i })).toHaveCount(0);
258+
259+
// Verify no pin buttons exist
260+
await expect(page.locator("[aria-label^='Pin']")).toHaveCount(0);
261+
});

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export default function ActionsTab(props: ActionsTabProps) {
156156

157157
function handleIgnore(run: WorkflowRun) {
158158
ignoreItem({
159-
id: String(run.id),
159+
id: run.id,
160160
type: "workflowRun",
161161
repo: run.repoFullName,
162162
title: run.name,
@@ -174,7 +174,7 @@ export default function ActionsTab(props: ActionsTabProps) {
174174
const eventFilter = viewState.tabFilters.actions.event;
175175

176176
return props.workflowRuns.filter((run) => {
177-
if (ignoredIds.has(String(run.id))) return false;
177+
if (ignoredIds.has(run.id)) return false;
178178
if (!viewState.showPrRuns && run.isPrRun) return false;
179179
if (org && !run.repoFullName.startsWith(`${org}/`)) return false;
180180
if (repo && run.repoFullName !== repo) return false;

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { createSignal, createMemo, Show, Switch, Match, onMount, onCleanup } from "solid-js";
1+
import { createSignal, createMemo, createEffect, Show, Switch, Match, onMount, onCleanup } from "solid-js";
22
import { createStore, produce } from "solid-js/store";
33
import Header from "../layout/Header";
44
import TabBar, { TabId } from "../layout/TabBar";
55
import FilterBar from "../layout/FilterBar";
66
import ActionsTab from "./ActionsTab";
77
import IssuesTab from "./IssuesTab";
88
import PullRequestsTab from "./PullRequestsTab";
9+
import TrackedTab from "./TrackedTab";
910
import PersonalSummaryStrip from "./PersonalSummaryStrip";
1011
import { config, setConfig, type TrackedUser } from "../../stores/config";
11-
import { viewState, updateViewState, setSortPreference } from "../../stores/view";
12+
import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems } from "../../stores/view";
1213
import type { SortOption } from "../shared/SortDropdown";
1314
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
1415
import { fetchOrgs } from "../../services/api";
@@ -93,9 +94,13 @@ function resetDashboardData(): void {
9394
localStorage.removeItem?.(DASHBOARD_STORAGE_KEY);
9495
}
9596

97+
const [hasFetchedFresh, setHasFetchedFresh] = createSignal(false);
98+
export function _resetHasFetchedFresh(value = false) { setHasFetchedFresh(value); }
99+
96100
// Clear dashboard data and stop polling on logout to prevent cross-user data leakage
97101
onAuthCleared(() => {
98102
resetDashboardData();
103+
setHasFetchedFresh(false);
99104
const coord = _coordinator();
100105
if (coord) {
101106
coord.destroy();
@@ -138,6 +143,7 @@ async function pollFetch(): Promise<DashboardData> {
138143
});
139144
// When notifications gate says nothing changed, keep existing data
140145
if (!data.skipped) {
146+
setHasFetchedFresh(true);
141147
const now = new Date();
142148

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

246-
const initialTab = createMemo<TabId>(() => {
247-
if (config.rememberLastTab) {
248-
return viewState.lastActiveTab;
249-
}
250-
return config.defaultTab;
251-
});
252+
function resolveInitialTab(): TabId {
253+
const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab;
254+
if (tab === "tracked" && !config.enableTracking) return "issues";
255+
return tab;
256+
}
252257

253-
const [activeTab, setActiveTab] = createSignal<TabId>(initialTab());
258+
const [activeTab, setActiveTab] = createSignal<TabId>(resolveInitialTab());
254259

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

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

267+
// Redirect away from tracked tab when tracking is disabled at runtime
268+
createEffect(() => {
269+
if (!config.enableTracking && activeTab() === "tracked") {
270+
handleTabChange("issues");
271+
}
272+
});
273+
274+
// Auto-prune tracked items that are closed/merged (absent from is:open results)
275+
createEffect(() => {
276+
// IMPORTANT: Access reactive store fields BEFORE early-return guards
277+
// so SolidJS registers them as dependencies even when the guard short-circuits
278+
const issues = dashboardData.issues;
279+
const prs = dashboardData.pullRequests;
280+
if (!config.enableTracking || viewState.trackedItems.length === 0 || !hasFetchedFresh()) return;
281+
282+
const polledRepos = new Set([
283+
...config.selectedRepos.map((r) => r.fullName),
284+
...config.upstreamRepos.map((r) => r.fullName),
285+
]);
286+
const liveIssueIds = new Set(issues.map((i) => i.id));
287+
const livePrIds = new Set(prs.map((p) => p.id));
288+
289+
const pruneKeys = new Set<string>();
290+
for (const item of viewState.trackedItems) {
291+
if (!polledRepos.has(item.repoFullName)) continue; // repo deselected — keep item
292+
const isLive = item.type === "issue" ? liveIssueIds.has(item.id) : livePrIds.has(item.id);
293+
if (!isLive) pruneKeys.add(`${item.type}:${item.id}`);
294+
}
295+
if (pruneKeys.size > 0) pruneClosedTrackedItems(pruneKeys);
296+
});
297+
262298
onMount(() => {
263299
if (!_coordinator()) {
264300
_setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch));
@@ -292,6 +328,22 @@ export default function DashboardPage() {
292328
run.completedAt = update.completedAt;
293329
}
294330
}));
331+
// Prune tracked PRs that became closed/merged via hot poll.
332+
// The auto-prune createEffect only fires when the pullRequests array
333+
// reference changes (full refresh). Hot poll mutates nested pr.state
334+
// in-place via produce(), leaving the array ref unchanged.
335+
if (config.enableTracking && viewState.trackedItems.length > 0 && prUpdates.size > 0) {
336+
const pruneKeys = new Set<string>();
337+
for (const [prId, update] of prUpdates) {
338+
const stateVal = update.state?.toUpperCase();
339+
if (stateVal === "CLOSED" || stateVal === "MERGED") {
340+
if (viewState.trackedItems.some(t => t.type === "pullRequest" && t.id === prId)) {
341+
pruneKeys.add(`pullRequest:${prId}`);
342+
}
343+
}
344+
}
345+
if (pruneKeys.size > 0) pruneClosedTrackedItems(pruneKeys);
346+
}
295347
},
296348
{
297349
onStart: (prDbIds, runIds) => {
@@ -353,25 +405,26 @@ export default function DashboardPage() {
353405

354406
return {
355407
issues: dashboardData.issues.filter((i) => {
356-
if (ignoredIssues.has(String(i.id))) return false;
408+
if (ignoredIssues.has(i.id)) return false;
357409
if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") return false;
358410
if (repo && i.repoFullName !== repo) return false;
359411
if (org && !i.repoFullName.startsWith(org + "/")) return false;
360412
return true;
361413
}).length,
362414
pullRequests: dashboardData.pullRequests.filter((p) => {
363-
if (ignoredPRs.has(String(p.id))) return false;
415+
if (ignoredPRs.has(p.id)) return false;
364416
if (repo && p.repoFullName !== repo) return false;
365417
if (org && !p.repoFullName.startsWith(org + "/")) return false;
366418
return true;
367419
}).length,
368420
actions: dashboardData.workflowRuns.filter((w) => {
369-
if (ignoredRuns.has(String(w.id))) return false;
421+
if (ignoredRuns.has(w.id)) return false;
370422
if (!viewState.showPrRuns && w.isPrRun) return false;
371423
if (repo && w.repoFullName !== repo) return false;
372424
if (org && !w.repoFullName.startsWith(org + "/")) return false;
373425
return true;
374426
}).length,
427+
...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}),
375428
};
376429
});
377430

@@ -404,6 +457,7 @@ export default function DashboardPage() {
404457
activeTab={activeTab()}
405458
onTabChange={handleTabChange}
406459
counts={tabCounts()}
460+
enableTracking={config.enableTracking}
407461
/>
408462

409463
<FilterBar
@@ -442,6 +496,13 @@ export default function DashboardPage() {
442496
refreshTick={refreshTick()}
443497
/>
444498
</Match>
499+
<Match when={activeTab() === "tracked"}>
500+
<TrackedTab
501+
issues={dashboardData.issues}
502+
pullRequests={dashboardData.pullRequests}
503+
refreshTick={refreshTick()}
504+
/>
505+
</Match>
445506
<Match when={activeTab() === "actions"}>
446507
<ActionsTab
447508
workflowRuns={dashboardData.workflowRuns}

src/app/components/dashboard/IgnoreBadge.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Tooltip } from "../shared/Tooltip";
44

55
interface IgnoreBadgeProps {
66
items: IgnoredItem[];
7-
onUnignore: (id: string) => void;
7+
onUnignore: (id: number) => void;
88
}
99

1010
function typeIcon(type: IgnoredItem["type"]): string {

0 commit comments

Comments
 (0)