From cf31f9bf009d29ac004855544b80b74540747051 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 10:04:26 -0400 Subject: [PATCH 1/5] feat(ui): adds tracked items tab with pin, reorder, and auto-prune --- e2e/helpers.ts | 7 +- e2e/smoke.spec.ts | 53 ++++ .../components/dashboard/DashboardPage.tsx | 63 ++++- src/app/components/dashboard/IssuesTab.tsx | 17 +- src/app/components/dashboard/ItemRow.tsx | 87 +++++-- .../components/dashboard/PullRequestsTab.tsx | 17 +- src/app/components/dashboard/TrackedTab.tsx | 151 +++++++++++ src/app/components/layout/TabBar.tsx | 12 +- src/app/components/settings/SettingsPage.tsx | 40 ++- src/app/stores/config.ts | 3 +- src/app/stores/view.ts | 69 ++++- tests/components/DashboardPage.test.tsx | 85 +++++++ tests/components/ItemRow.test.tsx | 54 ++++ tests/components/dashboard/IssuesTab.test.tsx | 86 ++++++- .../dashboard/PersonalSummaryStrip.test.tsx | 2 +- .../dashboard/PullRequestsTab.test.tsx | 86 ++++++- .../components/dashboard/TrackedTab.test.tsx | 240 ++++++++++++++++++ tests/components/layout/TabBar.test.tsx | 44 ++++ .../components/settings/SettingsPage.test.tsx | 68 +++++ tests/helpers/index.tsx | 12 + tests/stores/config.test.ts | 21 ++ tests/stores/view.test.ts | 192 +++++++++++++- 22 files changed, 1354 insertions(+), 55 deletions(-) create mode 100644 src/app/components/dashboard/TrackedTab.tsx create mode 100644 tests/components/dashboard/TrackedTab.test.tsx diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 68185738..4399d1db 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -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) { // Catch-all: abort any unmocked GitHub API request so failures are loud await page.route("https://api.github.com/**", (route) => route.abort()); @@ -46,7 +46,7 @@ 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", @@ -54,7 +54,8 @@ export async function setupAuth(page: Page) { selectedOrgs: ["testorg"], selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], onboardingComplete: true, + ...overrides, }) ); - }); + }, configOverrides ?? {}); } diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 22b05fd1..5530a528 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -188,3 +188,56 @@ 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 }) => { + // Override the GraphQL mock to return an issue + 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 setupAuth(page, { enableTracking: true }); + await page.goto("/dashboard"); + + // Verify Tracked tab is visible + await expect(page.getByRole("tab", { name: /tracked/i })).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); +}); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index ac31a75c..1d7ae03d 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,9 +6,10 @@ 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"; @@ -93,9 +94,13 @@ function resetDashboardData(): void { localStorage.removeItem?.(DASHBOARD_STORAGE_KEY); } +let hasFetchedFresh = false; +export function _resetHasFetchedFresh(value = false) { hasFetchedFresh = value; } + // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { resetDashboardData(); + hasFetchedFresh = false; const coord = _coordinator(); if (coord) { coord.destroy(); @@ -138,6 +143,7 @@ async function pollFetch(): Promise { }); // When notifications gate says nothing changed, keep existing data if (!data.skipped) { + hasFetchedFresh = true; const now = new Date(); if (phaseOneFired) { @@ -243,14 +249,13 @@ export default function DashboardPage() { const [hotPollingPRIds, setHotPollingPRIds] = createSignal>(new Set()); const [hotPollingRunIds, setHotPollingRunIds] = createSignal>(new Set()); - const initialTab = createMemo(() => { - 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(initialTab()); + const [activeTab, setActiveTab] = createSignal(resolveInitialTab()); function handleTabChange(tab: TabId) { setActiveTab(tab); @@ -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 non-reactive guards + // so SolidJS registers them as dependencies + 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(); + 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)); @@ -372,6 +408,7 @@ export default function DashboardPage() { if (org && !w.repoFullName.startsWith(org + "/")) return false; return true; }).length, + ...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}), }; }); @@ -404,6 +441,7 @@ export default function DashboardPage() { activeTab={activeTab()} onTabChange={handleTabChange} counts={tabCounts()} + enableTracking={config.enableTracking} /> + + + + config.enableTracking + ? new Set(viewState.trackedItems.filter(t => t.type === "issue").map(t => t.id)) + : new Set() + ); + const highlightedReposIssues = createReorderHighlight( () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.issues, @@ -235,6 +241,7 @@ export default function IssuesTab(props: IssuesTabProps) { title: issue.title, ignoredAt: Date.now(), }); + if (config.enableTracking) untrackItem(issue.id, "issue"); } return ( @@ -394,6 +401,14 @@ export default function IssuesTab(props: IssuesTabProps) { url={issue.htmlUrl} labels={issue.labels} onIgnore={() => handleIgnore(issue)} + onTrack={config.enableTracking ? () => { + if (trackedIssueIds().has(issue.id)) { + untrackItem(issue.id, "issue"); + } else { + trackItem({ id: issue.id, type: "issue", repoFullName: issue.repoFullName, title: issue.title, addedAt: Date.now() }); + } + } : undefined} + isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} density={config.viewDensity} commentCount={issue.comments} surfacedByBadge={ diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 28054b0e..427409b8 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -16,7 +16,9 @@ export interface ItemRowProps { url: string; labels: { name: string; color: string }[]; children?: JSX.Element; - onIgnore: () => void; + onIgnore?: () => void; + onTrack?: () => void; + isTracked?: boolean; density: "compact" | "comfortable"; commentCount?: number; hideRepo?: boolean; @@ -174,35 +176,64 @@ export default function ItemRow(props: ItemRowProps) { + {/* Pin button — visible on hover, always visible when tracked */} + + + + + + {/* Ignore button — visible on hover */} - - - + {/* Eye-slash icon */} + + + + ); } diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 4e21dcc5..7f8d607f 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; -import { viewState, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; +import { viewState, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory, formatStarCount } from "../../lib/format"; import { isSafeGitHubUrl } from "../../lib/url"; @@ -326,6 +326,12 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { itemStatus: (pr) => pr.checkStatus ?? pr.reviewDecision ?? "updated", }); + const trackedPrIds = createMemo(() => + config.enableTracking + ? new Set(viewState.trackedItems.filter(t => t.type === "pullRequest").map(t => t.id)) + : new Set() + ); + const highlightedReposPRs = createReorderHighlight( () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.pullRequests, @@ -341,6 +347,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { title: pr.title, ignoredAt: Date.now(), }); + if (config.enableTracking) untrackItem(pr.id, "pullRequest"); } return ( @@ -553,6 +560,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { labels={pr.labels} commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined} onIgnore={() => handleIgnore(pr)} + onTrack={config.enableTracking ? () => { + if (trackedPrIds().has(pr.id)) { + untrackItem(pr.id, "pullRequest"); + } else { + trackItem({ id: pr.id, type: "pullRequest", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); + } + } : undefined} + isTracked={config.enableTracking ? trackedPrIds().has(pr.id) : undefined} density={config.viewDensity} surfacedByBadge={ props.trackedUsers && props.trackedUsers.length > 0 diff --git a/src/app/components/dashboard/TrackedTab.tsx b/src/app/components/dashboard/TrackedTab.tsx new file mode 100644 index 00000000..dfeac876 --- /dev/null +++ b/src/app/components/dashboard/TrackedTab.tsx @@ -0,0 +1,151 @@ +import { For, Show, createMemo } from "solid-js"; +import { config } from "../../stores/config"; +import { viewState, ignoreItem, untrackItem, moveTrackedItem } from "../../stores/view"; +import type { Issue, PullRequest } from "../../services/api"; +import ItemRow from "./ItemRow"; + +export interface TrackedTabProps { + issues: Issue[]; + pullRequests: PullRequest[]; + refreshTick?: number; +} + +export default function TrackedTab(props: TrackedTabProps) { + const maps = createMemo(() => { + const issueMap = new Map(); + for (const issue of props.issues) { + issueMap.set(issue.id, issue); + } + const prMap = new Map(); + for (const pr of props.pullRequests) { + prMap.set(pr.id, pr); + } + return { issueMap, prMap }; + }); + + const trackedItems = () => viewState.trackedItems; + + return ( +
+ 0} + fallback={ +
+ No tracked items. Pin issues or PRs from the Issues and Pull Requests tabs. +
+ } + > +
+ + {(item, index) => { + const liveData = () => + item.type === "issue" + ? maps().issueMap.get(item.id) + : maps().prMap.get(item.id); + + const isFirst = () => index() === 0; + const isLast = () => index() === trackedItems().length - 1; + + return ( +
+ {/* Arrow buttons */} +
+ + +
+ + {/* Row content */} +
+ +
+
+ + {item.title} + + + Issue + + + PR + +
+
+ {item.repoFullName}{" "} + (not in current data) +
+
+ +
+ } + > + {(live) => ( + untrackItem(item.id, item.type)} + isTracked={true} + onIgnore={() => { + ignoreItem({ + id: String(item.id), + type: item.type, + repo: live().repoFullName, + title: live().title, + ignoredAt: Date.now(), + }); + untrackItem(item.id, item.type); + }} + density={config.viewDensity} + > + + Issue + + + PR + + + )} + +
+
+ ); + }} + +
+ + + ); +} diff --git a/src/app/components/layout/TabBar.tsx b/src/app/components/layout/TabBar.tsx index 4b7f6c91..5d0caa16 100644 --- a/src/app/components/layout/TabBar.tsx +++ b/src/app/components/layout/TabBar.tsx @@ -1,18 +1,20 @@ import { Tabs } from "@kobalte/core/tabs"; import { Show } from "solid-js"; -export type TabId = "issues" | "pullRequests" | "actions"; +export type TabId = "issues" | "pullRequests" | "actions" | "tracked"; export interface TabCounts { issues?: number; pullRequests?: number; actions?: number; + tracked?: number; } interface TabBarProps { activeTab: TabId; onTabChange: (tab: TabId) => void; counts?: TabCounts; + enableTracking?: boolean; } export default function TabBar(props: TabBarProps) { @@ -39,6 +41,14 @@ export default function TabBar(props: TabBarProps) { {props.counts?.actions} + + + Tracked + + {props.counts?.tracked} + + + diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index ce136bf9..ad4d06fc 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -1,6 +1,8 @@ import { createSignal, createMemo, Show, onCleanup } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { config, updateConfig, setMonitoredRepo } from "../../stores/config"; +import type { Config } from "../../stores/config"; +import { viewState, updateViewState } from "../../stores/view"; import { clearAuth } from "../../stores/auth"; import { clearCache } from "../../stores/cache"; import { pushNotification } from "../../lib/errors"; @@ -159,6 +161,7 @@ export default function SettingsPage() { itemsPerPage: config.itemsPerPage, defaultTab: config.defaultTab, rememberLastTab: config.rememberLastTab, + enableTracking: config.enableTracking, }, null, 2 @@ -200,11 +203,12 @@ export default function SettingsPage() { { value: 0, label: "Off" }, ]; - const tabOptions = [ + const tabOptions = createMemo(() => [ { value: "issues" as const, label: "Issues" }, { value: "pullRequests" as const, label: "Pull Requests" }, { value: "actions" as const, label: "GitHub Actions" }, - ]; + ...(config.enableTracking ? [{ value: "tracked" as const, label: "Tracked Items" }] : []), + ]); const densityOptions = [ { value: "comfortable" as const, label: "Comfortable" }, @@ -580,11 +584,11 @@ export default function SettingsPage() { @@ -603,6 +607,34 @@ export default function SettingsPage() { class="toggle toggle-primary" /> + + { + const val = e.currentTarget.checked; + if (!val) { + if (config.defaultTab === "tracked") { + saveWithFeedback({ enableTracking: val, defaultTab: "issues" }); + } else { + saveWithFeedback({ enableTracking: val }); + } + if (viewState.lastActiveTab === "tracked") { + updateViewState({ lastActiveTab: "issues" }); + } + } else { + saveWithFeedback({ enableTracking: val }); + } + }} + class="toggle toggle-primary" + /> + {/* Section 8: Data */} diff --git a/src/app/stores/config.ts b/src/app/stores/config.ts index a6dcd1bd..fabe933c 100644 --- a/src/app/stores/config.ts +++ b/src/app/stores/config.ts @@ -59,10 +59,11 @@ export const ConfigSchema = z.object({ theme: z.enum(THEME_OPTIONS).default("auto"), viewDensity: z.enum(["compact", "comfortable"]).default("comfortable"), itemsPerPage: z.number().min(10).max(100).default(25), - defaultTab: z.enum(["issues", "pullRequests", "actions"]).default("issues"), + defaultTab: z.enum(["issues", "pullRequests", "actions", "tracked"]).default("issues"), rememberLastTab: z.boolean().default(true), onboardingComplete: z.boolean().default(false), authMethod: z.enum(["oauth", "pat"]).default("oauth"), + enableTracking: z.boolean().default(false), }); export type Config = z.infer; diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 18738eb0..0bb8a54c 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -5,6 +5,17 @@ import { pushNotification } from "../lib/errors"; export const VIEW_STORAGE_KEY = "github-tracker:view"; const IGNORED_ITEMS_CAP = 500; +const TRACKED_ITEMS_CAP = 200; + +export const TrackedItemSchema = z.object({ + id: z.number(), + type: z.enum(["issue", "pullRequest"]), + repoFullName: z.string(), + title: z.string(), + addedAt: z.number(), +}); + +export type TrackedItem = z.infer; export const IssueFiltersSchema = z.object({ scope: z.enum(["involves_me", "all"]).default("involves_me"), @@ -37,7 +48,7 @@ export type ActionsFilterField = keyof ActionsFilters; export const ViewStateSchema = z.object({ lastActiveTab: z - .enum(["issues", "pullRequests", "actions"]) + .enum(["issues", "pullRequests", "actions", "tracked"]) .default("issues"), globalSort: z.object({ field: z.string(), @@ -90,6 +101,7 @@ export const ViewStateSchema = z.object({ pullRequests: [], actions: [], }), + trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]), }); export type ViewState = z.infer; @@ -128,6 +140,7 @@ export function resetViewState(): void { hideDepDashboard: true, expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, lockedRepos: { issues: [], pullRequests: [], actions: [] }, + trackedItems: [], }); } @@ -327,6 +340,60 @@ export function pruneLockedRepos( })); } +export function trackItem(item: TrackedItem): void { + setViewState( + produce((draft) => { + const already = draft.trackedItems.some( + (i) => i.id === item.id && i.type === item.type + ); + if (!already) { + // FIFO eviction: remove oldest if at cap + if (draft.trackedItems.length >= TRACKED_ITEMS_CAP) { + draft.trackedItems.shift(); + } + draft.trackedItems.push(item); + } + }) + ); +} + +export function untrackItem(id: number, type: "issue" | "pullRequest"): void { + setViewState( + produce((draft) => { + draft.trackedItems = draft.trackedItems.filter( + (i) => !(i.id === id && i.type === type) + ); + }) + ); +} + +export function moveTrackedItem( + id: number, + type: "issue" | "pullRequest", + direction: "up" | "down" +): void { + setViewState(produce((draft) => { + const arr = draft.trackedItems; + const idx = arr.findIndex((i) => i.id === id && i.type === type); + if (idx === -1) return; + const targetIdx = direction === "up" ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= arr.length) return; + const tmp = arr[idx]; + arr[idx] = arr[targetIdx]; + arr[targetIdx] = tmp; + })); +} + +export function pruneClosedTrackedItems(pruneKeys: Set): void { + setViewState( + produce((draft) => { + draft.trackedItems = draft.trackedItems.filter( + (i) => !pruneKeys.has(`${i.type}:${i.id}`) + ); + }) + ); +} + export function initViewPersistence(): void { let debounceTimer: ReturnType | undefined; createEffect(() => { diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index eb5146e2..b609235b 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -70,9 +70,11 @@ let capturedOnHotData: (( // DashboardPage and pollService are imported dynamically after each vi.resetModules() // so the module-level _coordinator variable is always fresh (null) per test. let DashboardPage: typeof import("../../src/app/components/dashboard/DashboardPage").default; +let _resetHasFetchedFresh: typeof import("../../src/app/components/dashboard/DashboardPage")._resetHasFetchedFresh; let pollService: typeof import("../../src/app/services/poll"); let authStore: typeof import("../../src/app/stores/auth"); let viewStore: typeof import("../../src/app/stores/view"); +let configStore: typeof import("../../src/app/stores/config"); beforeEach(async () => { // Clear localStorage so loadCachedDashboard doesn't pick up stale data from prior tests @@ -121,9 +123,11 @@ beforeEach(async () => { // Re-import with fresh module instances const dashboardModule = await import("../../src/app/components/dashboard/DashboardPage"); DashboardPage = dashboardModule.default; + _resetHasFetchedFresh = dashboardModule._resetHasFetchedFresh; pollService = await import("../../src/app/services/poll"); authStore = await import("../../src/app/stores/auth"); viewStore = await import("../../src/app/stores/view"); + configStore = await import("../../src/app/stores/config"); mockLocationReplace.mockClear(); capturedFetchAll = null; @@ -812,3 +816,84 @@ describe("DashboardPage — onHotData integration", () => { expect(screen.getByText(/1 workflow/)).toBeTruthy(); }); }); + +describe("DashboardPage — tracked tab", () => { + it("renders Tracked tab when enableTracking is true", () => { + configStore.updateConfig({ enableTracking: true }); + render(() => ); + expect(screen.getByText("Tracked")).toBeTruthy(); + }); + + it("does not render Tracked tab when enableTracking is false", () => { + configStore.updateConfig({ enableTracking: false }); + render(() => ); + expect(screen.queryByText("Tracked")).toBeNull(); + }); + + it("auto-prunes tracked items absent from open poll data", async () => { + render(() => ); + configStore.updateConfig({ + enableTracking: true, + selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], + }); + viewStore.updateViewState({ + trackedItems: [{ + id: 999, + type: "issue" as const, + repoFullName: "org/repo", + title: "Will be pruned", + addedAt: Date.now(), + }], + }); + _resetHasFetchedFresh(true); + + // Trigger poll with empty issues — item 999 absent means it was closed + if (capturedFetchAll) { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll(); + } + + await waitFor(() => { + expect(viewStore.viewState.trackedItems.length).toBe(0); + }); + }); + + it("preserves tracked items from deselected repos", async () => { + render(() => ); + configStore.updateConfig({ + enableTracking: true, + selectedRepos: [{ owner: "org", name: "other-repo", fullName: "org/other-repo" }], + }); + viewStore.updateViewState({ + trackedItems: [{ + id: 888, + type: "issue" as const, + repoFullName: "org/deselected-repo", + title: "Should be kept", + addedAt: Date.now(), + }], + }); + _resetHasFetchedFresh(true); + + if (capturedFetchAll) { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll(); + } + + // Item from deselected repo should NOT be pruned + await waitFor(() => { + expect(viewStore.viewState.trackedItems.length).toBe(1); + expect(viewStore.viewState.trackedItems[0].id).toBe(888); + }); + }); +}); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 0b2185ca..a964dd09 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -353,6 +353,60 @@ describe("ItemRow", () => { vi.useRealTimers(); }); + it("does not render pin button when onTrack is undefined", () => { + render(() => ); + expect(screen.queryByLabelText(/Pin #42/i)).toBeNull(); + expect(screen.queryByLabelText(/Unpin #42/i)).toBeNull(); + }); + + it("renders pin button when onTrack is provided", () => { + render(() => ); + expect(screen.getByLabelText(/Pin #42/i)).not.toBeNull(); + }); + + it("calls onTrack when pin button clicked", async () => { + const user = userEvent.setup(); + const onTrack = vi.fn(); + render(() => ); + await user.click(screen.getByLabelText(/Pin #42/i)); + expect(onTrack).toHaveBeenCalledOnce(); + }); + + it("shows filled pin icon (solid bookmark) when isTracked is true", () => { + render(() => ( + + )); + // Solid bookmark uses fill="currentColor" with fill-rule="evenodd" and has no stroke attr + const btn = screen.getByLabelText(/Unpin #42/i); + const svg = btn.querySelector("svg"); + expect(svg?.getAttribute("fill")).toBe("currentColor"); + }); + + it("shows outline pin icon (outline bookmark) when isTracked is false", () => { + render(() => ( + + )); + const btn = screen.getByLabelText(/Pin #42/i); + const svg = btn.querySelector("svg"); + expect(svg?.getAttribute("fill")).toBe("none"); + }); + + it("pin button has aria-label 'Unpin' when isTracked is true", () => { + render(() => ); + expect(screen.getByLabelText("Unpin #42 Fix a bug")).not.toBeNull(); + }); + + it("pin button has aria-label 'Pin' when isTracked is false", () => { + render(() => ); + expect(screen.getByLabelText("Pin #42 Fix a bug")).not.toBeNull(); + }); + + it("does not render ignore button when onIgnore is undefined", () => { + const { onIgnore: _onIgnore, ...propsWithoutIgnore } = defaultProps; + render(() => ); + expect(screen.queryByLabelText(/Ignore #42/i)).toBeNull(); + }); + it("refreshTick forces time display update", () => { const [tick, setTick] = createSignal(0); let mockNow = MOCK_NOW; diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index d0c84f63..c10acd25 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; -import { makeIssue } from "../../helpers/index"; +import userEvent from "@testing-library/user-event"; +import { makeIssue, makeTrackedItem } from "../../helpers/index"; // ── localStorage mock ───────────────────────────────────────────────────────── @@ -29,7 +30,8 @@ vi.mock("../../../src/app/lib/url", () => ({ // ── Imports ─────────────────────────────────────────────────────────────────── import IssuesTab from "../../../src/app/components/dashboard/IssuesTab"; -import { viewState, setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import { viewState, setTabFilter, setAllExpanded, resetViewState, updateViewState } from "../../../src/app/stores/view"; +import { updateConfig, resetConfig } from "../../../src/app/stores/config"; import type { TrackedUser } from "../../../src/app/stores/config"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -38,6 +40,7 @@ beforeEach(() => { vi.clearAllMocks(); localStorageMock.clear(); resetViewState(); + resetConfig(); }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -606,3 +609,82 @@ describe("IssuesTab — scope toggle visibility", () => { expect(viewState.tabFilters.issues.scope).toBe("involves_me"); }); }); + +// ── IssuesTab — pin button wiring ───────────────────────────────────────────── + +describe("IssuesTab — pin button wiring", () => { + it("pin button not rendered when enableTracking is false", () => { + updateConfig({ enableTracking: false }); + const issue = makeIssue({ id: 1, title: "Pin test issue", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.queryByLabelText(/^Pin #/)).toBeNull(); + expect(screen.queryByLabelText(/^Unpin #/)).toBeNull(); + }); + + it("pin button rendered when enableTracking is true", async () => { + updateConfig({ enableTracking: true }); + const issue = makeIssue({ id: 1, title: "Pin test issue", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.getByLabelText(/^Pin #/)).not.toBeNull(); + }); + + it("clicking pin button on untracked issue tracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const issue = makeIssue({ id: 50, title: "My issue", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + const pinBtn = screen.getByLabelText(/^Pin #/); + await user.click(pinBtn); + + expect(viewState.trackedItems.some(t => t.id === 50 && t.type === "issue")).toBe(true); + }); + + it("clicking pin button on tracked issue untracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const issue = makeIssue({ id: 51, title: "Already tracked", repoFullName: "owner/repo", surfacedBy: ["me"] }); + updateViewState({ trackedItems: [makeTrackedItem({ id: 51, type: "issue", repoFullName: "owner/repo", title: "Already tracked" })] }); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + const unpinBtn = screen.getByLabelText(/^Unpin #/); + await user.click(unpinBtn); + + expect(viewState.trackedItems.some(t => t.id === 51 && t.type === "issue")).toBe(false); + }); + + it("ignoring an issue also untracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const issue = makeIssue({ id: 52, title: "Tracked and ignored", repoFullName: "owner/repo", surfacedBy: ["me"] }); + updateViewState({ trackedItems: [makeTrackedItem({ id: 52, type: "issue", repoFullName: "owner/repo", title: "Tracked and ignored" })] }); + setAllExpanded("issues", ["owner/repo"], true); + + render(() => ( + + )); + + const ignoreBtn = screen.getByLabelText(/Ignore #/); + await user.click(ignoreBtn); + + expect(viewState.trackedItems.some(t => t.id === 52 && t.type === "issue")).toBe(false); + }); +}); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index aecb6039..6d78fd80 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -21,7 +21,7 @@ function renderStrip(opts: { pullRequests?: PullRequest[]; workflowRuns?: WorkflowRun[]; userLogin?: string; - onTabChange?: (tab: "issues" | "pullRequests" | "actions") => void; + onTabChange?: (tab: import("../../../src/app/components/layout/TabBar").TabId) => void; }) { const onTabChange = opts.onTabChange ?? vi.fn(); return render(() => ( diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 640cc118..4ef55da8 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; -import { makePullRequest } from "../../helpers/index"; +import userEvent from "@testing-library/user-event"; +import { makePullRequest, makeTrackedItem } from "../../helpers/index"; // ── localStorage mock ───────────────────────────────────────────────────────── @@ -29,8 +30,9 @@ vi.mock("../../../src/app/lib/url", () => ({ // ── Imports ─────────────────────────────────────────────────────────────────── import PullRequestsTab from "../../../src/app/components/dashboard/PullRequestsTab"; -import { viewState, setTabFilter, setAllExpanded, resetViewState } from "../../../src/app/stores/view"; +import { viewState, setTabFilter, setAllExpanded, resetViewState, updateViewState } from "../../../src/app/stores/view"; import type { TrackedUser } from "../../../src/app/stores/config"; +import { updateConfig, resetConfig } from "../../../src/app/stores/config"; // ── Setup ───────────────────────────────────────────────────────────────────── @@ -38,6 +40,7 @@ beforeEach(() => { vi.clearAllMocks(); localStorageMock.clear(); resetViewState(); + resetConfig(); }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -577,3 +580,82 @@ describe("PullRequestsTab — reviewDecision=mergeable filter", () => { expect(screen.queryByText("Changes PR")).toBeNull(); }); }); + +// ── PullRequestsTab — pin button wiring ─────────────────────────────────────── + +describe("PullRequestsTab — pin button wiring", () => { + it("pin button not rendered when enableTracking is false", () => { + updateConfig({ enableTracking: false }); + const pr = makePullRequest({ id: 1, title: "Pin test PR", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.queryByLabelText(/^Pin #/)).toBeNull(); + expect(screen.queryByLabelText(/^Unpin #/)).toBeNull(); + }); + + it("pin button rendered when enableTracking is true", () => { + updateConfig({ enableTracking: true }); + const pr = makePullRequest({ id: 1, title: "Pin test PR", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + expect(screen.getByLabelText(/^Pin #/)).not.toBeNull(); + }); + + it("clicking pin button on untracked PR tracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const pr = makePullRequest({ id: 60, title: "My PR", repoFullName: "owner/repo", surfacedBy: ["me"] }); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + const pinBtn = screen.getByLabelText(/^Pin #/); + await user.click(pinBtn); + + expect(viewState.trackedItems.some(t => t.id === 60 && t.type === "pullRequest")).toBe(true); + }); + + it("clicking pin button on tracked PR untracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const pr = makePullRequest({ id: 61, title: "Already tracked PR", repoFullName: "owner/repo", surfacedBy: ["me"] }); + updateViewState({ trackedItems: [makeTrackedItem({ id: 61, type: "pullRequest", repoFullName: "owner/repo", title: "Already tracked PR" })] }); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + const unpinBtn = screen.getByLabelText(/^Unpin #/); + await user.click(unpinBtn); + + expect(viewState.trackedItems.some(t => t.id === 61 && t.type === "pullRequest")).toBe(false); + }); + + it("ignoring a PR also untracks it", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + const pr = makePullRequest({ id: 62, title: "Tracked and ignored PR", repoFullName: "owner/repo", surfacedBy: ["me"] }); + updateViewState({ trackedItems: [makeTrackedItem({ id: 62, type: "pullRequest", repoFullName: "owner/repo", title: "Tracked and ignored PR" })] }); + setAllExpanded("pullRequests", ["owner/repo"], true); + + render(() => ( + + )); + + const ignoreBtn = screen.getByLabelText(/Ignore #/); + await user.click(ignoreBtn); + + expect(viewState.trackedItems.some(t => t.id === 62 && t.type === "pullRequest")).toBe(false); + }); +}); diff --git a/tests/components/dashboard/TrackedTab.test.tsx b/tests/components/dashboard/TrackedTab.test.tsx new file mode 100644 index 00000000..06d6f684 --- /dev/null +++ b/tests/components/dashboard/TrackedTab.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { makeIssue, makePullRequest, makeTrackedItem } from "../../helpers/index"; + +// ── localStorage mock ───────────────────────────────────────────────────────── + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/app/lib/url", () => ({ + isSafeGitHubUrl: () => true, +})); + +// ── Imports ─────────────────────────────────────────────────────────────────── + +import TrackedTab from "../../../src/app/components/dashboard/TrackedTab"; +import { viewState, resetViewState, updateViewState } from "../../../src/app/stores/view"; + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + resetViewState(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("TrackedTab — empty state", () => { + it("renders empty state when no items tracked", () => { + render(() => ); + expect(screen.getByText(/No tracked items/)).toBeTruthy(); + }); +}); + +describe("TrackedTab — type badges", () => { + it("renders tracked issues with type badge", () => { + const issue = makeIssue({ id: 1, title: "My issue" }); + const tracked = makeTrackedItem({ id: 1, type: "issue", title: "My issue" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + const badges = screen.getAllByText("Issue"); + expect(badges.length).toBeGreaterThan(0); + }); + + it("renders tracked PRs with type badge", () => { + const pr = makePullRequest({ id: 2, title: "My PR" }); + const tracked = makeTrackedItem({ id: 2, type: "pullRequest", title: "My PR" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + const badges = screen.getAllByText("PR"); + expect(badges.length).toBeGreaterThan(0); + }); +}); + +describe("TrackedTab — ordering", () => { + it("renders items in tracked order (not alphabetical)", () => { + const issue1 = makeIssue({ id: 10, title: "Zebra Issue" }); + const issue2 = makeIssue({ id: 11, title: "Apple Issue" }); + const issue3 = makeIssue({ id: 12, title: "Mango Issue" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 10, type: "issue", title: "Zebra Issue" }), + makeTrackedItem({ id: 11, type: "issue", title: "Apple Issue" }), + makeTrackedItem({ id: 12, type: "issue", title: "Mango Issue" }), + ], + }); + + render(() => ); + + const titles = screen.getAllByText(/Issue/).filter( + (el) => el.classList.contains("badge") === false && el.textContent !== "Issue" + ); + // The items should appear in tracked order: Zebra, Apple, Mango + const zebra = screen.getByText("Zebra Issue"); + const apple = screen.getByText("Apple Issue"); + const mango = screen.getByText("Mango Issue"); + + // Verify DOM order: Zebra comes before Apple, Apple comes before Mango + expect( + zebra.compareDocumentPosition(apple) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + apple.compareDocumentPosition(mango) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + void titles; + }); +}); + +describe("TrackedTab — fallback row", () => { + it("shows minimal row when live data not found", () => { + const tracked = makeTrackedItem({ id: 999, type: "issue", title: "Missing Issue" }); + updateViewState({ trackedItems: [tracked] }); + + // Don't pass the issue with id 999 in props — simulating it's not in current data + render(() => ); + + expect(screen.getByText(/not in current data/)).toBeTruthy(); + expect(screen.getByText("Missing Issue")).toBeTruthy(); + }); +}); + +describe("TrackedTab — move button disabled states", () => { + it("move up button disabled on first item", () => { + const issue1 = makeIssue({ id: 20, title: "First Item" }); + const issue2 = makeIssue({ id: 21, title: "Second Item" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 20, type: "issue", title: "First Item" }), + makeTrackedItem({ id: 21, type: "issue", title: "Second Item" }), + ], + }); + + render(() => ); + + const upButtons = screen.getAllByLabelText(/^Move up:/); + expect((upButtons[0] as HTMLButtonElement).disabled).toBe(true); + expect((upButtons[1] as HTMLButtonElement).disabled).toBe(false); + }); + + it("move down button disabled on last item", () => { + const issue1 = makeIssue({ id: 30, title: "First Item" }); + const issue2 = makeIssue({ id: 31, title: "Second Item" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 30, type: "issue", title: "First Item" }), + makeTrackedItem({ id: 31, type: "issue", title: "Second Item" }), + ], + }); + + render(() => ); + + const downButtons = screen.getAllByLabelText(/^Move down:/); + expect((downButtons[0] as HTMLButtonElement).disabled).toBe(false); + expect((downButtons[1] as HTMLButtonElement).disabled).toBe(true); + }); +}); + +describe("TrackedTab — reordering", () => { + it("clicking move up reorders items", () => { + const issue1 = makeIssue({ id: 40, title: "First Item" }); + const issue2 = makeIssue({ id: 41, title: "Second Item" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 40, type: "issue", title: "First Item" }), + makeTrackedItem({ id: 41, type: "issue", title: "Second Item" }), + ], + }); + + render(() => ); + + const upButtons = screen.getAllByLabelText(/^Move up:/); + // Click move up on second item + fireEvent.click(upButtons[1]); + + // Second item (id=41) should now be first + expect(viewState.trackedItems[0].id).toBe(41); + expect(viewState.trackedItems[1].id).toBe(40); + }); + + it("clicking move down reorders items", () => { + const issue1 = makeIssue({ id: 50, title: "First Item" }); + const issue2 = makeIssue({ id: 51, title: "Second Item" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 50, type: "issue", title: "First Item" }), + makeTrackedItem({ id: 51, type: "issue", title: "Second Item" }), + ], + }); + + render(() => ); + + const downButtons = screen.getAllByLabelText(/^Move down:/); + // Click move down on first item + fireEvent.click(downButtons[0]); + + // First item (id=50) should now be second + expect(viewState.trackedItems[0].id).toBe(51); + expect(viewState.trackedItems[1].id).toBe(50); + }); +}); + +describe("TrackedTab — pin button", () => { + it("clicking pin button untracks item", () => { + const issue = makeIssue({ id: 60, title: "Tracked Issue" }); + const tracked = makeTrackedItem({ id: 60, type: "issue", title: "Tracked Issue" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + // Find the Unpin button (pin button in TrackedTab always has isTracked=true) + const unpinBtn = screen.getByLabelText(`Unpin #${issue.number} ${issue.title}`); + fireEvent.click(unpinBtn); + + expect(viewState.trackedItems).toHaveLength(0); + }); +}); + +describe("TrackedTab — ignore", () => { + it("ignoring a tracked item removes it from both lists", () => { + const issue = makeIssue({ id: 70, title: "Ignorable Issue" }); + const tracked = makeTrackedItem({ id: 70, type: "issue", title: "Ignorable Issue" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + const ignoreBtn = screen.getByLabelText(`Ignore #${issue.number} ${issue.title}`); + fireEvent.click(ignoreBtn); + + // Removed from trackedItems + expect(viewState.trackedItems).toHaveLength(0); + // Added to ignoredItems + expect(viewState.ignoredItems).toHaveLength(1); + expect(viewState.ignoredItems[0].id).toBe("70"); + }); +}); diff --git a/tests/components/layout/TabBar.test.tsx b/tests/components/layout/TabBar.test.tsx index 868b41f8..19468c92 100644 --- a/tests/components/layout/TabBar.test.tsx +++ b/tests/components/layout/TabBar.test.tsx @@ -99,4 +99,48 @@ describe("TabBar", () => { // PR and Actions counts should not appear expect(screen.queryByText("0")).toBeNull(); }); + + it("does not render Tracked tab when enableTracking is false", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.queryByRole("tab", { name: /Tracked/i })).toBeNull(); + }); + + it("does not render Tracked tab when enableTracking is undefined", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + expect(screen.queryByRole("tab", { name: /Tracked/i })).toBeNull(); + }); + + it("renders Tracked tab when enableTracking is true", () => { + const onTabChange = vi.fn(); + render(() => ( + + )); + screen.getByRole("tab", { name: /Tracked/i }); + }); + + it("shows tracked count badge when enableTracking is true and count provided", () => { + const onTabChange = vi.fn(); + const counts: TabCounts = { tracked: 4 }; + render(() => ( + + )); + screen.getByText("4"); + }); + + it("calls onTabChange with 'tracked' when Tracked tab clicked", async () => { + const user = userEvent.setup(); + const onTabChange = vi.fn(); + render(() => ( + + )); + const trackedTab = screen.getByRole("tab", { name: /Tracked/i }); + await user.click(trackedTab); + expect(onTabChange).toHaveBeenCalledWith("tracked"); + }); }); diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 1e8ae380..82e81d6d 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -683,6 +683,74 @@ describe("SettingsPage — Manage org access button", () => { }); }); +describe("SettingsPage — enableTracking toggle", () => { + it("renders enableTracking toggle with aria-label 'Enable tracked items'", () => { + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable tracked items/i }); + expect(toggle).toBeDefined(); + }); + + it("toggles enableTracking setting", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: false }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable tracked items/i }); + await user.click(toggle); + expect(config.enableTracking).toBe(true); + }); + + it("disabling tracking resets defaultTab to 'issues' when it was 'tracked'", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true, defaultTab: "tracked" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable tracked items/i }); + await user.click(toggle); + expect(config.enableTracking).toBe(false); + expect(config.defaultTab).toBe("issues"); + }); + + it("disabling tracking preserves defaultTab when it was not 'tracked'", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true, defaultTab: "pullRequests" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable tracked items/i }); + await user.click(toggle); + expect(config.enableTracking).toBe(false); + expect(config.defaultTab).toBe("pullRequests"); + }); + + it("shows 'Tracked Items' option in defaultTab select when enableTracking is true", () => { + updateConfig({ enableTracking: true }); + renderSettings(); + screen.getByRole("option", { name: "Tracked Items" }); + }); + + it("hides 'Tracked Items' option in defaultTab select when enableTracking is false", () => { + updateConfig({ enableTracking: false }); + renderSettings(); + expect(screen.queryByRole("option", { name: "Tracked Items" })).toBeNull(); + }); + + it("includes enableTracking in exported settings JSON", async () => { + updateConfig({ enableTracking: true }); + renderSettings(); + const exportBtn = screen.getByRole("button", { name: /export/i }); + const user = userEvent.setup(); + const blobParts: BlobPart[] = []; + const originalBlob = globalThis.Blob; + globalThis.Blob = class MockBlob extends originalBlob { + constructor(parts?: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + if (parts) blobParts.push(...parts); + } + } as typeof Blob; + await user.click(exportBtn); + globalThis.Blob = originalBlob; + const json = JSON.parse(blobParts[0] as string); + expect(json.enableTracking).toBe(true); + }); +}); + describe("SettingsPage — monitor toggle wiring", () => { it("shows monitored repos indicator when repos are monitored", () => { updateConfig({ diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 8bd3a53f..2dd5d139 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -1,6 +1,7 @@ import { render } from "@solidjs/testing-library"; import { MemoryRouter, createMemoryHistory } from "@solidjs/router"; import { resetViewState } from "../../src/app/stores/view"; +import type { TrackedItem } from "../../src/app/stores/view"; import type { Issue, PullRequest, WorkflowRun, ApiError } from "../../src/app/services/api"; import type { JSX } from "solid-js"; @@ -82,6 +83,17 @@ export function makeWorkflowRun(overrides: Partial = {}): WorkflowR }; } +export function makeTrackedItem(overrides: Partial = {}): TrackedItem { + return { + id: nextId++, + type: "issue", + repoFullName: "owner/repo", + title: "Test tracked item", + addedAt: Date.now(), + ...overrides, + }; +} + export function makeApiError(overrides: Partial = {}): ApiError { return { repo: "owner/repo", diff --git a/tests/stores/config.test.ts b/tests/stores/config.test.ts index d1e212b0..dc16ee64 100644 --- a/tests/stores/config.test.ts +++ b/tests/stores/config.test.ts @@ -582,6 +582,27 @@ describe("setMonitoredRepo (C3)", () => { }); }); +describe("ConfigSchema — enableTracking", () => { + it("defaults enableTracking to false", () => { + const result = ConfigSchema.parse({}); + expect(result.enableTracking).toBe(false); + }); + + it("accepts enableTracking: true", () => { + const result = ConfigSchema.parse({ enableTracking: true }); + expect(result.enableTracking).toBe(true); + }); + + it("defaultTab accepts 'tracked'", () => { + const result = ConfigSchema.parse({ defaultTab: "tracked" }); + expect(result.defaultTab).toBe("tracked"); + }); + + it("defaultTab rejects invalid value", () => { + expect(() => ConfigSchema.parse({ defaultTab: "invalid" })).toThrow(); + }); +}); + describe("ConfigSchema — monitoredRepos max constraint", () => { it("rejects more than 10 monitored repos at schema level", () => { const repos = Array.from({ length: 11 }, (_, i) => ({ diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index ffc065ad..2314a4db 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -16,8 +16,12 @@ import { toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, + trackItem, + untrackItem, + moveTrackedItem, + pruneClosedTrackedItems, } from "../../src/app/stores/view"; -import type { IgnoredItem } from "../../src/app/stores/view"; +import type { IgnoredItem, TrackedItem } from "../../src/app/stores/view"; // view.ts uses createStore — setters work outside reactive context. // We use createRoot only for initViewPersistence (which calls createEffect). @@ -406,3 +410,189 @@ describe("resetAllTabFilters — scope reset", () => { expect(viewState.hideDepDashboard).toBe(true); }); }); + +describe("tracked items", () => { + const item1: TrackedItem = { + id: 1001, + type: "issue", + repoFullName: "owner/repo", + title: "Bug fix", + addedAt: 1711000000000, + }; + const item2: TrackedItem = { + id: 2002, + type: "pullRequest", + repoFullName: "owner/repo", + title: "Add feature", + addedAt: 1711000001000, + }; + const item3: TrackedItem = { + id: 3003, + type: "issue", + repoFullName: "owner/other", + title: "Another issue", + addedAt: 1711000002000, + }; + + describe("trackItem", () => { + it("adds an item to trackedItems", () => { + trackItem(item1); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].id).toBe(1001); + }); + + it("does not add duplicate (same id+type)", () => { + trackItem(item1); + trackItem(item1); + expect(viewState.trackedItems).toHaveLength(1); + }); + + it("allows same id with different type", () => { + trackItem(item1); // id:1001, type:issue + trackItem({ ...item1, type: "pullRequest" }); // id:1001, type:pullRequest + expect(viewState.trackedItems).toHaveLength(2); + }); + + it("can add multiple distinct items", () => { + trackItem(item1); + trackItem(item2); + expect(viewState.trackedItems).toHaveLength(2); + }); + + it("evicts oldest item when at 200 cap (FIFO)", () => { + // Fill to 200 + for (let i = 0; i < 200; i++) { + trackItem({ id: i, type: "issue", repoFullName: "o/r", title: `T${i}`, addedAt: 1000 + i }); + } + expect(viewState.trackedItems).toHaveLength(200); + + // Adding 201st should evict item with id:0 (oldest) + trackItem({ id: 9999, type: "issue", repoFullName: "o/r", title: "New", addedAt: 2000 }); + expect(viewState.trackedItems).toHaveLength(200); + expect(viewState.trackedItems[0].id).toBe(1); // id:0 evicted + expect(viewState.trackedItems[199].id).toBe(9999); + }); + }); + + describe("untrackItem", () => { + it("removes the item with the given id+type", () => { + trackItem(item1); + trackItem(item2); + untrackItem(1001, "issue"); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].id).toBe(2002); + }); + + it("is a no-op for unknown id+type", () => { + trackItem(item1); + untrackItem(9999, "issue"); + expect(viewState.trackedItems).toHaveLength(1); + }); + + it("does not remove item if type does not match", () => { + trackItem(item1); // id:1001, type:issue + untrackItem(1001, "pullRequest"); // different type + expect(viewState.trackedItems).toHaveLength(1); + }); + }); + + describe("moveTrackedItem", () => { + it("moves item up by swapping with predecessor", () => { + trackItem(item1); + trackItem(item2); + trackItem(item3); + // Order: item1, item2, item3 → move item2 up → item2, item1, item3 + moveTrackedItem(2002, "pullRequest", "up"); + expect(viewState.trackedItems[0].id).toBe(2002); + expect(viewState.trackedItems[1].id).toBe(1001); + expect(viewState.trackedItems[2].id).toBe(3003); + }); + + it("moves item down by swapping with successor", () => { + trackItem(item1); + trackItem(item2); + trackItem(item3); + // Order: item1, item2, item3 → move item2 down → item1, item3, item2 + moveTrackedItem(2002, "pullRequest", "down"); + expect(viewState.trackedItems[0].id).toBe(1001); + expect(viewState.trackedItems[1].id).toBe(3003); + expect(viewState.trackedItems[2].id).toBe(2002); + }); + + it("is a no-op when moving first item up", () => { + trackItem(item1); + trackItem(item2); + moveTrackedItem(1001, "issue", "up"); + expect(viewState.trackedItems[0].id).toBe(1001); + expect(viewState.trackedItems[1].id).toBe(2002); + }); + + it("is a no-op when moving last item down", () => { + trackItem(item1); + trackItem(item2); + moveTrackedItem(2002, "pullRequest", "down"); + expect(viewState.trackedItems[0].id).toBe(1001); + expect(viewState.trackedItems[1].id).toBe(2002); + }); + + it("is a no-op for unknown id+type", () => { + trackItem(item1); + moveTrackedItem(9999, "issue", "up"); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].id).toBe(1001); + }); + }); + + describe("pruneClosedTrackedItems", () => { + it("removes items whose type:id key is in pruneKeys", () => { + trackItem(item1); // issue:1001 + trackItem(item2); // pullRequest:2002 + trackItem(item3); // issue:3003 + pruneClosedTrackedItems(new Set(["issue:1001", "issue:3003"])); + expect(viewState.trackedItems).toHaveLength(1); + expect(viewState.trackedItems[0].id).toBe(2002); + }); + + it("is a no-op when pruneKeys is empty", () => { + trackItem(item1); + trackItem(item2); + pruneClosedTrackedItems(new Set()); + expect(viewState.trackedItems).toHaveLength(2); + }); + + it("is a no-op when no tracked items match pruneKeys", () => { + trackItem(item1); + pruneClosedTrackedItems(new Set(["pullRequest:9999"])); + expect(viewState.trackedItems).toHaveLength(1); + }); + + it("removes all items when all keys match", () => { + trackItem(item1); + trackItem(item2); + pruneClosedTrackedItems(new Set(["issue:1001", "pullRequest:2002"])); + expect(viewState.trackedItems).toHaveLength(0); + }); + }); + + describe("resetViewState clears trackedItems", () => { + it("resets trackedItems to empty array", () => { + trackItem(item1); + trackItem(item2); + expect(viewState.trackedItems).toHaveLength(2); + resetViewState(); + expect(viewState.trackedItems).toHaveLength(0); + }); + }); + + describe("ViewStateSchema — trackedItems", () => { + it("defaults trackedItems to empty array", () => { + const result = ViewStateSchema.parse({}); + expect(result.trackedItems).toEqual([]); + }); + + it("accepts lastActiveTab value 'tracked'", () => { + const result = ViewStateSchema.parse({ lastActiveTab: "tracked" }); + expect(result.lastActiveTab).toBe("tracked"); + }); + }); +}); From 030f6cfd8168fc45ae2b83f55e00aa5d5936f64a Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 10:08:01 -0400 Subject: [PATCH 2/5] fix(dashboard): converts hasFetchedFresh to createSignal --- src/app/components/dashboard/DashboardPage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 1d7ae03d..7e470dca 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -94,13 +94,13 @@ function resetDashboardData(): void { localStorage.removeItem?.(DASHBOARD_STORAGE_KEY); } -let hasFetchedFresh = false; -export function _resetHasFetchedFresh(value = false) { hasFetchedFresh = value; } +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(); - hasFetchedFresh = false; + setHasFetchedFresh(false); const coord = _coordinator(); if (coord) { coord.destroy(); @@ -143,7 +143,7 @@ async function pollFetch(): Promise { }); // When notifications gate says nothing changed, keep existing data if (!data.skipped) { - hasFetchedFresh = true; + setHasFetchedFresh(true); const now = new Date(); if (phaseOneFired) { @@ -277,7 +277,7 @@ export default function DashboardPage() { // so SolidJS registers them as dependencies const issues = dashboardData.issues; const prs = dashboardData.pullRequests; - if (!config.enableTracking || viewState.trackedItems.length === 0 || !hasFetchedFresh) return; + if (!config.enableTracking || viewState.trackedItems.length === 0 || !hasFetchedFresh()) return; const polledRepos = new Set([ ...config.selectedRepos.map((r) => r.fullName), From 4f60e87527564f1729afa5284e678c4b06b4cb2e Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 11:41:52 -0400 Subject: [PATCH 3/5] fix(ui): addresses PR review findings for tracked items feature --- e2e/smoke.spec.ts | 22 ++++- .../components/dashboard/DashboardPage.tsx | 4 +- src/app/components/dashboard/IssuesTab.tsx | 16 ++-- .../components/dashboard/PullRequestsTab.tsx | 16 ++-- src/app/components/dashboard/TrackedTab.tsx | 56 ++++++----- src/app/stores/view.ts | 1 + tests/components/DashboardPage.test.tsx | 94 ++++++++++++++++++- tests/components/ItemRow.test.tsx | 2 +- .../components/dashboard/TrackedTab.test.tsx | 40 +++++++- .../components/settings/SettingsPage.test.tsx | 12 +++ tests/helpers/index.tsx | 4 +- tests/stores/view.test.ts | 7 +- 12 files changed, 221 insertions(+), 53 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 5530a528..1647ed04 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -192,7 +192,9 @@ test("unknown path redirects to dashboard when authenticated", async ({ page }) // ── Tracked items ───────────────────────────────────────────────────────────── test("tracked items tab appears when enabled", async ({ page }) => { - // Override the GraphQL mock to return an issue + // 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, @@ -224,11 +226,27 @@ test("tracked items tab appears when enabled", async ({ page }) => { }, }) ); - await setupAuth(page, { enableTracking: true }); 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 }) => { diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 7e470dca..a051fff3 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -273,8 +273,8 @@ export default function DashboardPage() { // Auto-prune tracked items that are closed/merged (absent from is:open results) createEffect(() => { - // IMPORTANT: Access reactive store fields BEFORE non-reactive guards - // so SolidJS registers them as dependencies + // 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; diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 560b6007..102c2371 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -244,6 +244,14 @@ export default function IssuesTab(props: IssuesTabProps) { if (config.enableTracking) untrackItem(issue.id, "issue"); } + function handleTrack(issue: Issue) { + if (trackedIssueIds().has(issue.id)) { + untrackItem(issue.id, "issue"); + } else { + trackItem({ id: issue.id, number: issue.number, type: "issue", repoFullName: issue.repoFullName, title: issue.title, addedAt: Date.now() }); + } + } + return (
{/* Filter chips + ignore badge toolbar */} @@ -401,13 +409,7 @@ export default function IssuesTab(props: IssuesTabProps) { url={issue.htmlUrl} labels={issue.labels} onIgnore={() => handleIgnore(issue)} - onTrack={config.enableTracking ? () => { - if (trackedIssueIds().has(issue.id)) { - untrackItem(issue.id, "issue"); - } else { - trackItem({ id: issue.id, type: "issue", repoFullName: issue.repoFullName, title: issue.title, addedAt: Date.now() }); - } - } : undefined} + onTrack={config.enableTracking ? () => handleTrack(issue) : undefined} isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} density={config.viewDensity} commentCount={issue.comments} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 7f8d607f..d960979a 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -350,6 +350,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (config.enableTracking) untrackItem(pr.id, "pullRequest"); } + function handleTrack(pr: PullRequest) { + if (trackedPrIds().has(pr.id)) { + untrackItem(pr.id, "pullRequest"); + } else { + trackItem({ id: pr.id, number: pr.number, type: "pullRequest", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); + } + } + return (
{/* Filter toolbar */} @@ -560,13 +568,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { labels={pr.labels} commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined} onIgnore={() => handleIgnore(pr)} - onTrack={config.enableTracking ? () => { - if (trackedPrIds().has(pr.id)) { - untrackItem(pr.id, "pullRequest"); - } else { - trackItem({ id: pr.id, type: "pullRequest", repoFullName: pr.repoFullName, title: pr.title, addedAt: Date.now() }); - } - } : undefined} + onTrack={config.enableTracking ? () => handleTrack(pr) : undefined} isTracked={config.enableTracking ? trackedPrIds().has(pr.id) : undefined} density={config.viewDensity} surfacedByBadge={ diff --git a/src/app/components/dashboard/TrackedTab.tsx b/src/app/components/dashboard/TrackedTab.tsx index dfeac876..c929e4c4 100644 --- a/src/app/components/dashboard/TrackedTab.tsx +++ b/src/app/components/dashboard/TrackedTab.tsx @@ -1,8 +1,9 @@ -import { For, Show, createMemo } from "solid-js"; +import { For, Show, Switch, Match, createMemo } from "solid-js"; import { config } from "../../stores/config"; import { viewState, ignoreItem, untrackItem, moveTrackedItem } from "../../stores/view"; import type { Issue, PullRequest } from "../../services/api"; import ItemRow from "./ItemRow"; +import { Tooltip } from "../shared/Tooltip"; export interface TrackedTabProps { issues: Issue[]; @@ -23,12 +24,10 @@ export default function TrackedTab(props: TrackedTabProps) { return { issueMap, prMap }; }); - const trackedItems = () => viewState.trackedItems; - return (
0} + when={viewState.trackedItems.length > 0} fallback={
No tracked items. Pin issues or PRs from the Issues and Pull Requests tabs. @@ -36,7 +35,7 @@ export default function TrackedTab(props: TrackedTabProps) { } >
- + {(item, index) => { const liveData = () => item.type === "issue" @@ -44,7 +43,7 @@ export default function TrackedTab(props: TrackedTabProps) { : maps().prMap.get(item.id); const isFirst = () => index() === 0; - const isLast = () => index() === trackedItems().length - 1; + const isLast = () => index() === viewState.trackedItems.length - 1; return (
@@ -80,27 +79,30 @@ export default function TrackedTab(props: TrackedTabProps) { {item.title} - - Issue - - - PR - + + + Issue + + + PR + +
{item.repoFullName}{" "} (not in current data)
- + + +
} > @@ -130,12 +132,14 @@ export default function TrackedTab(props: TrackedTabProps) { }} density={config.viewDensity} > - - Issue - - - PR - + + + Issue + + + PR + + )}
diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 0bb8a54c..0001a526 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -9,6 +9,7 @@ const TRACKED_ITEMS_CAP = 200; export const TrackedItemSchema = z.object({ id: z.number(), + number: z.number(), type: z.enum(["issue", "pullRequest"]), repoFullName: z.string(), title: z.string(), diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index b609235b..59177eac 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@solidjs/testing-library"; +import { render, screen, waitFor, fireEvent } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { makeIssue, makePullRequest, makeWorkflowRun } from "../helpers/index"; import type { DashboardData } from "../../src/app/services/poll"; @@ -839,6 +839,7 @@ describe("DashboardPage — tracked tab", () => { viewStore.updateViewState({ trackedItems: [{ id: 999, + number: 99, type: "issue" as const, repoFullName: "org/repo", title: "Will be pruned", @@ -872,6 +873,7 @@ describe("DashboardPage — tracked tab", () => { viewStore.updateViewState({ trackedItems: [{ id: 888, + number: 88, type: "issue" as const, repoFullName: "org/deselected-repo", title: "Should be kept", @@ -896,4 +898,94 @@ describe("DashboardPage — tracked tab", () => { expect(viewStore.viewState.trackedItems[0].id).toBe(888); }); }); + + it("does not prune tracked items when hasFetchedFresh is false (cold start)", async () => { + render(() => ); + configStore.updateConfig({ + enableTracking: true, + selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], + }); + viewStore.updateViewState({ + trackedItems: [{ + id: 777, + number: 77, + type: "issue" as const, + repoFullName: "org/repo", + title: "Should survive cold start", + addedAt: Date.now(), + }], + }); + // hasFetchedFresh stays false (its initial state) — do NOT call _resetHasFetchedFresh(true) + // Do NOT trigger a poll (which would set hasFetchedFresh=true internally). + // The prune effect should not fire against stale cached data. + + // Allow reactive effects to settle + await waitFor(() => { + // Item should NOT be pruned — hasFetchedFresh is false + expect(viewStore.viewState.trackedItems.length).toBe(1); + expect(viewStore.viewState.trackedItems[0].id).toBe(777); + }); + }); + + it("prunes tracked items from upstream repos", async () => { + render(() => ); + configStore.updateConfig({ + enableTracking: true, + selectedRepos: [], + upstreamRepos: [{ owner: "ext", name: "upstream", fullName: "ext/upstream" }], + }); + viewStore.updateViewState({ + trackedItems: [{ + id: 666, + number: 66, + type: "issue" as const, + repoFullName: "ext/upstream", + title: "Upstream item closed", + addedAt: Date.now(), + }], + }); + _resetHasFetchedFresh(true); + + if (capturedFetchAll) { + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll(); + } + + await waitFor(() => { + expect(viewStore.viewState.trackedItems.length).toBe(0); + }); + }); + + it("resolveInitialTab falls back to issues when tracked tab disabled", () => { + viewStore.updateViewState({ lastActiveTab: "tracked" }); + configStore.updateConfig({ rememberLastTab: true, enableTracking: false }); + render(() => ); + // Should show Issues content, not Tracked content + expect(screen.queryByText("No tracked items")).toBeNull(); + }); + + it("redirects away from tracked tab when tracking disabled at runtime", async () => { + configStore.updateConfig({ enableTracking: true }); + render(() => ); + + // Switch to tracked tab + const trackedTab = screen.getByText("Tracked"); + fireEvent.click(trackedTab); + + await waitFor(() => { + expect(viewStore.viewState.lastActiveTab).toBe("tracked"); + }); + + // Disable tracking — should redirect to issues + configStore.updateConfig({ enableTracking: false }); + + await waitFor(() => { + expect(viewStore.viewState.lastActiveTab).toBe("issues"); + }); + }); }); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index a964dd09..a7071c57 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -101,7 +101,7 @@ describe("ItemRow", () => { expect(onIgnore).toHaveBeenCalledOnce(); }); - it("ignore button has relative z-10 to sit above overlay link", () => { + it("ignore button wrapper has relative z-10 to sit above overlay link", () => { render(() => ); const ignoreBtn = screen.getByLabelText(/Ignore #42/i); // The Tooltip wrapper span carries relative z-10; the button itself is inside it diff --git a/tests/components/dashboard/TrackedTab.test.tsx b/tests/components/dashboard/TrackedTab.test.tsx index 06d6f684..68a8e61d 100644 --- a/tests/components/dashboard/TrackedTab.test.tsx +++ b/tests/components/dashboard/TrackedTab.test.tsx @@ -88,9 +88,6 @@ describe("TrackedTab — ordering", () => { render(() => ); - const titles = screen.getAllByText(/Issue/).filter( - (el) => el.classList.contains("badge") === false && el.textContent !== "Issue" - ); // The items should appear in tracked order: Zebra, Apple, Mango const zebra = screen.getByText("Zebra Issue"); const apple = screen.getByText("Apple Issue"); @@ -103,7 +100,6 @@ describe("TrackedTab — ordering", () => { expect( apple.compareDocumentPosition(mango) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); - void titles; }); }); @@ -118,6 +114,18 @@ describe("TrackedTab — fallback row", () => { expect(screen.getByText(/not in current data/)).toBeTruthy(); expect(screen.getByText("Missing Issue")).toBeTruthy(); }); + + it("clicking fallback unpin button untracks item", () => { + const tracked = makeTrackedItem({ id: 888, type: "issue", title: "Stale Item" }); + updateViewState({ trackedItems: [tracked] }); + + render(() => ); + + const unpinBtn = screen.getByLabelText("Unpin #888 Stale Item"); + fireEvent.click(unpinBtn); + + expect(viewState.trackedItems).toHaveLength(0); + }); }); describe("TrackedTab — move button disabled states", () => { @@ -204,6 +212,30 @@ describe("TrackedTab — reordering", () => { }); }); +describe("TrackedTab — mixed types", () => { + it("renders both issues and PRs with correct type badges", () => { + const issue = makeIssue({ id: 80, title: "Mixed Issue" }); + const pr = makePullRequest({ id: 81, title: "Mixed PR" }); + + updateViewState({ + trackedItems: [ + makeTrackedItem({ id: 80, type: "issue", title: "Mixed Issue" }), + makeTrackedItem({ id: 81, type: "pullRequest", title: "Mixed PR" }), + ], + }); + + render(() => ); + + expect(screen.getByText("Mixed Issue")).toBeTruthy(); + expect(screen.getByText("Mixed PR")).toBeTruthy(); + + const issueBadges = screen.getAllByText("Issue"); + const prBadges = screen.getAllByText("PR"); + expect(issueBadges.length).toBeGreaterThan(0); + expect(prBadges.length).toBeGreaterThan(0); + }); +}); + describe("TrackedTab — pin button", () => { it("clicking pin button untracks item", () => { const issue = makeIssue({ id: 60, title: "Tracked Issue" }); diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 82e81d6d..18d5e079 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -59,6 +59,7 @@ import * as authStore from "../../../src/app/stores/auth"; import * as cacheStore from "../../../src/app/stores/cache"; import * as apiModule from "../../../src/app/services/api"; import { updateConfig, config } from "../../../src/app/stores/config"; +import { viewState, updateViewState } from "../../../src/app/stores/view"; import { buildOrgAccessUrl } from "../../../src/app/lib/oauth"; import * as urlModule from "../../../src/app/lib/url"; @@ -749,6 +750,17 @@ describe("SettingsPage — enableTracking toggle", () => { const json = JSON.parse(blobParts[0] as string); expect(json.enableTracking).toBe(true); }); + + it("disabling tracking resets lastActiveTab to 'issues' when it was 'tracked'", async () => { + const user = userEvent.setup(); + updateConfig({ enableTracking: true }); + updateViewState({ lastActiveTab: "tracked" }); + renderSettings(); + const toggle = screen.getByRole("switch", { name: /enable tracked items/i }); + await user.click(toggle); + expect(config.enableTracking).toBe(false); + expect(viewState.lastActiveTab).toBe("issues"); + }); }); describe("SettingsPage — monitor toggle wiring", () => { diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 2dd5d139..d4b595b3 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -84,8 +84,10 @@ export function makeWorkflowRun(overrides: Partial = {}): WorkflowR } export function makeTrackedItem(overrides: Partial = {}): TrackedItem { + const id = overrides.id ?? nextId++; return { - id: nextId++, + id, + number: id, type: "issue", repoFullName: "owner/repo", title: "Test tracked item", diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 2314a4db..200db43b 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -414,6 +414,7 @@ describe("resetAllTabFilters — scope reset", () => { describe("tracked items", () => { const item1: TrackedItem = { id: 1001, + number: 101, type: "issue", repoFullName: "owner/repo", title: "Bug fix", @@ -421,6 +422,7 @@ describe("tracked items", () => { }; const item2: TrackedItem = { id: 2002, + number: 202, type: "pullRequest", repoFullName: "owner/repo", title: "Add feature", @@ -428,6 +430,7 @@ describe("tracked items", () => { }; const item3: TrackedItem = { id: 3003, + number: 303, type: "issue", repoFullName: "owner/other", title: "Another issue", @@ -462,12 +465,12 @@ describe("tracked items", () => { it("evicts oldest item when at 200 cap (FIFO)", () => { // Fill to 200 for (let i = 0; i < 200; i++) { - trackItem({ id: i, type: "issue", repoFullName: "o/r", title: `T${i}`, addedAt: 1000 + i }); + trackItem({ id: i, number: i, type: "issue", repoFullName: "o/r", title: `T${i}`, addedAt: 1000 + i }); } expect(viewState.trackedItems).toHaveLength(200); // Adding 201st should evict item with id:0 (oldest) - trackItem({ id: 9999, type: "issue", repoFullName: "o/r", title: "New", addedAt: 2000 }); + trackItem({ id: 9999, number: 9999, type: "issue", repoFullName: "o/r", title: "New", addedAt: 2000 }); expect(viewState.trackedItems).toHaveLength(200); expect(viewState.trackedItems[0].id).toBe(1); // id:0 evicted expect(viewState.trackedItems[199].id).toBe(9999); From ee486452f257ff8022d686b6c8b2231de17b0338 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 15:33:35 -0400 Subject: [PATCH 4/5] 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 --- src/app/components/dashboard/ActionsTab.tsx | 4 +- .../components/dashboard/DashboardPage.tsx | 22 +++- src/app/components/dashboard/IgnoreBadge.tsx | 2 +- src/app/components/dashboard/IssuesTab.tsx | 4 +- .../dashboard/PersonalSummaryStrip.tsx | 8 +- .../components/dashboard/PullRequestsTab.tsx | 4 +- src/app/components/dashboard/TrackedTab.tsx | 102 ++++++++++++------ src/app/components/settings/SettingsPage.tsx | 17 ++- src/app/stores/view.ts | 4 +- tests/components/ActionsTab.test.tsx | 2 +- tests/components/DashboardPage.test.tsx | 73 +++++++++++-- tests/components/IgnoreBadge.test.tsx | 22 ++-- tests/components/IssuesTab.test.tsx | 4 +- tests/components/PullRequestsTab.test.tsx | 4 +- .../dashboard/PersonalSummaryStrip.test.tsx | 8 +- .../components/dashboard/TrackedTab.test.tsx | 14 +-- tests/stores/view.test.ts | 28 ++--- 17 files changed, 210 insertions(+), 112 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 3511a8bb..897860d9 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -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, @@ -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; diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index a051fff3..ad5aca9e 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -328,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(); + 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) => { @@ -389,20 +405,20 @@ 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; diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index ca96f994..cc5a44aa 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -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 { diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 102c2371..6197a41a 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -121,7 +121,7 @@ export default function IssuesTab(props: IssuesTabProps) { const meta = new Map }>(); let items = props.issues.filter((issue) => { - if (ignored.has(String(issue.id))) return false; + if (ignored.has(issue.id)) return false; if (filter.repo && issue.repoFullName !== filter.repo) return false; if (filter.org && !issue.repoFullName.startsWith(filter.org + "/")) return false; @@ -235,7 +235,7 @@ export default function IssuesTab(props: IssuesTabProps) { function handleIgnore(issue: Issue) { ignoreItem({ - id: String(issue.id), + id: issue.id, type: "issue", repo: issue.repoFullName, title: issue.title, diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 86282dc0..d5faa7fa 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -22,7 +22,7 @@ interface PersonalSummaryStripProps { export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { const ignoredIds = createMemo(() => { - const ids = new Set(); + const ids = new Set(); for (const item of viewState.ignoredItems) ids.add(item.id); return ids; }); @@ -34,7 +34,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { const ignored = ignoredIds(); let assignedIssues = 0; for (const i of props.issues) { - if (ignored.has(String(i.id))) continue; + if (ignored.has(i.id)) continue; if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") continue; if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++; } @@ -50,7 +50,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { let prsReadyToMerge = 0; let prsBlocked = 0; for (const pr of props.pullRequests) { - if (ignored.has(String(pr.id))) continue; + if (ignored.has(pr.id)) continue; const isAuthor = pr.userLogin.toLowerCase() === login; if ( !isAuthor && @@ -81,7 +81,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { const runningActions = createMemo(() => { const ignored = ignoredIds(); - return props.workflowRuns.filter((r) => !ignored.has(String(r.id)) && r.status === "in_progress").length; + return props.workflowRuns.filter((r) => !ignored.has(r.id) && r.status === "in_progress").length; }); const summaryItems = createMemo(() => { diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index d960979a..7ef80977 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -190,7 +190,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const meta = new Map; sizeCategory: ReturnType }>(); let items = props.pullRequests.filter((pr) => { - if (ignored.has(String(pr.id))) return false; + if (ignored.has(pr.id)) return false; if (filter.repo && pr.repoFullName !== filter.repo) return false; if (filter.org && !pr.repoFullName.startsWith(filter.org + "/")) return false; @@ -341,7 +341,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { function handleIgnore(pr: PullRequest) { ignoreItem({ - id: String(pr.id), + id: pr.id, type: "pullRequest", repo: pr.repoFullName, title: pr.title, diff --git a/src/app/components/dashboard/TrackedTab.tsx b/src/app/components/dashboard/TrackedTab.tsx index c929e4c4..0c58b7cb 100644 --- a/src/app/components/dashboard/TrackedTab.tsx +++ b/src/app/components/dashboard/TrackedTab.tsx @@ -1,10 +1,60 @@ import { For, Show, Switch, Match, createMemo } from "solid-js"; import { config } from "../../stores/config"; -import { viewState, ignoreItem, untrackItem, moveTrackedItem } from "../../stores/view"; +import { viewState, untrackItem, moveTrackedItem } from "../../stores/view"; +import type { TrackedItem } from "../../stores/view"; import type { Issue, PullRequest } from "../../services/api"; import ItemRow from "./ItemRow"; import { Tooltip } from "../shared/Tooltip"; +function TypeBadge(props: { type: TrackedItem["type"] }) { + return ( + + + Issue + + + PR + + + ); +} + +// FLIP animation: record positions before move, animate slide after DOM updates +const itemRefs = new Map(); +const prefersReducedMotion = () => + typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +function recordPositions(): Map { + const snapshot = new Map(); + for (const [key, el] of itemRefs) { + snapshot.set(key, el.getBoundingClientRect()); + } + return snapshot; +} + +function animateMove(before: Map) { + if (prefersReducedMotion()) return; + requestAnimationFrame(() => { + for (const [key, el] of itemRefs) { + const old = before.get(key); + if (!old) continue; + const now = el.getBoundingClientRect(); + const dy = old.top - now.top; + if (Math.abs(dy) < 1) continue; + el.animate( + [{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }], + { duration: 200, easing: "ease-in-out" } + ); + } + }); +} + +function handleMove(id: number, type: "issue" | "pullRequest", direction: "up" | "down") { + const before = recordPositions(); + moveTrackedItem(id, type, direction); + animateMove(before); +} + export interface TrackedTabProps { issues: Issue[]; pullRequests: PullRequest[]; @@ -44,26 +94,36 @@ export default function TrackedTab(props: TrackedTabProps) { const isFirst = () => index() === 0; const isLast = () => index() === viewState.trackedItems.length - 1; + const itemKey = `${item.type}:${item.id}`; return ( -
- {/* Arrow buttons */} +
{ itemRefs.set(itemKey, el); }} + > + {/* Reorder buttons */}
@@ -79,14 +139,7 @@ export default function TrackedTab(props: TrackedTabProps) { {item.title} - - - Issue - - - PR - - +
{item.repoFullName}{" "} @@ -120,26 +173,9 @@ export default function TrackedTab(props: TrackedTabProps) { labels={live().labels} onTrack={() => untrackItem(item.id, item.type)} isTracked={true} - onIgnore={() => { - ignoreItem({ - id: String(item.id), - type: item.type, - repo: live().repoFullName, - title: live().title, - ignoredAt: Date.now(), - }); - untrackItem(item.id, item.type); - }} density={config.viewDensity} > - - - Issue - - - PR - - + )} diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index ad4d06fc..8cbb95a7 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -619,17 +619,12 @@ export default function SettingsPage() { checked={config.enableTracking} onChange={(e) => { const val = e.currentTarget.checked; - if (!val) { - if (config.defaultTab === "tracked") { - saveWithFeedback({ enableTracking: val, defaultTab: "issues" }); - } else { - saveWithFeedback({ enableTracking: val }); - } - if (viewState.lastActiveTab === "tracked") { - updateViewState({ lastActiveTab: "issues" }); - } - } else { - saveWithFeedback({ enableTracking: val }); + saveWithFeedback({ + enableTracking: val, + ...(!val && config.defaultTab === "tracked" ? { defaultTab: "issues" as const } : {}), + }); + if (!val && viewState.lastActiveTab === "tracked") { + updateViewState({ lastActiveTab: "issues" }); } }} class="toggle toggle-primary" diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 0001a526..8a0372c5 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -58,7 +58,7 @@ export const ViewStateSchema = z.object({ ignoredItems: z .array( z.object({ - id: z.string(), + id: z.coerce.number(), type: z.enum(["issue", "pullRequest", "workflowRun"]), repo: z.string(), title: z.string(), @@ -168,7 +168,7 @@ export function ignoreItem(item: IgnoredItem): void { ); } -export function unignoreItem(id: string): void { +export function unignoreItem(id: number): void { setViewState( produce((draft) => { draft.ignoredItems = draft.ignoredItems.filter((i) => i.id !== id); diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx index 7c5d6796..9854957c 100644 --- a/tests/components/ActionsTab.test.tsx +++ b/tests/components/ActionsTab.test.tsx @@ -162,7 +162,7 @@ describe("ActionsTab", () => { it("filters out ignored workflow runs", () => { const run = makeWorkflowRun({ id: 42, name: "Ignored Run", repoFullName: "owner/repo" }); viewStore.ignoreItem({ - id: "42", + id: 42, type: "workflowRun", repo: "owner/repo", title: "Ignored Run", diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 59177eac..0ccb480b 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -142,6 +142,8 @@ beforeEach(async () => { }); // Reset view store to defaults viewStore.resetViewState(); + // Reset config store to defaults — prevents enableTracking, selectedRepos, etc. from leaking between tests + configStore.resetConfig(); }); describe("DashboardPage — tab switching", () => { @@ -269,13 +271,13 @@ describe("DashboardPage — tab badge counts", () => { }); // Ignore one item — badge should decrement to 1 - viewStore.ignoreItem({ id: "1", type: "issue", repo: "owner/repo", title: "Issue A", ignoredAt: Date.now() }); + viewStore.ignoreItem({ id: 1, type: "issue", repo: "owner/repo", title: "Issue A", ignoredAt: Date.now() }); await waitFor(() => { expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("1"); }); // Un-ignore — badge should increment back to 2 - viewStore.unignoreItem("1"); + viewStore.unignoreItem(1); await waitFor(() => { expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("2"); }); @@ -299,7 +301,7 @@ describe("DashboardPage — tab badge counts", () => { }); // Ignore one real issue — badge should drop to 1 - viewStore.ignoreItem({ id: "1", type: "issue", repo: "owner/repo", title: "Issue A", ignoredAt: Date.now() }); + viewStore.ignoreItem({ id: 1, type: "issue", repo: "owner/repo", title: "Issue A", ignoredAt: Date.now() }); await waitFor(() => { expect(screen.getByRole("tab", { name: /Issues/ }).textContent?.replace(/\D+/g, "")).toBe("1"); }); @@ -322,13 +324,13 @@ describe("DashboardPage — tab badge counts", () => { expect(screen.getByRole("tab", { name: /Pull Requests/ }).textContent?.replace(/\D+/g, "")).toBe("3"); }); - viewStore.ignoreItem({ id: "10", type: "pullRequest", repo: "owner/repo", title: "PR A", ignoredAt: Date.now() }); + viewStore.ignoreItem({ id: 10, type: "pullRequest", repo: "owner/repo", title: "PR A", ignoredAt: Date.now() }); await waitFor(() => { expect(screen.getByRole("tab", { name: /Pull Requests/ }).textContent?.replace(/\D+/g, "")).toBe("2"); }); // Un-ignore — badge should increment back to 3 - viewStore.unignoreItem("10"); + viewStore.unignoreItem(10); await waitFor(() => { expect(screen.getByRole("tab", { name: /Pull Requests/ }).textContent?.replace(/\D+/g, "")).toBe("3"); }); @@ -350,13 +352,13 @@ describe("DashboardPage — tab badge counts", () => { expect(screen.getByRole("tab", { name: /Actions/ }).textContent?.replace(/\D+/g, "")).toBe("2"); }); - viewStore.ignoreItem({ id: "20", type: "workflowRun", repo: "owner/repo", title: "CI", ignoredAt: Date.now() }); + viewStore.ignoreItem({ id: 20, type: "workflowRun", repo: "owner/repo", title: "CI", ignoredAt: Date.now() }); await waitFor(() => { expect(screen.getByRole("tab", { name: /Actions/ }).textContent?.replace(/\D+/g, "")).toBe("1"); }); // Un-ignore — badge should increment back to 2 - viewStore.unignoreItem("20"); + viewStore.unignoreItem(20); await waitFor(() => { expect(screen.getByRole("tab", { name: /Actions/ }).textContent?.replace(/\D+/g, "")).toBe("2"); }); @@ -424,7 +426,7 @@ describe("DashboardPage — tab badge counts", () => { }); // Ignore one PR-triggered run — badge should drop to 2 - viewStore.ignoreItem({ id: "21", type: "workflowRun", repo: "owner/repo", title: "CI", ignoredAt: Date.now() }); + viewStore.ignoreItem({ id: 21, type: "workflowRun", repo: "owner/repo", title: "CI", ignoredAt: Date.now() }); await waitFor(() => { expect(screen.getByRole("tab", { name: /Actions/ }).textContent?.replace(/\D+/g, "")).toBe("2"); }); @@ -598,6 +600,43 @@ describe("DashboardPage — data flow", () => { // Data still present (collapsed repo group summary persists) screen.getByText("1 issue"); }); + + it("auto-prune runs after first non-skipped poll even if a skipped poll occurred first", async () => { + configStore.updateConfig({ + enableTracking: true, + selectedRepos: [{ owner: "org", name: "repo", fullName: "org/repo" }], + }); + viewStore.updateViewState({ + trackedItems: [{ + id: 555, + number: 55, + type: "issue" as const, + repoFullName: "org/repo", + title: "Will be pruned after non-skipped poll", + addedAt: Date.now(), + }], + }); + + // First call: skipped — hasFetchedFresh must stay false, no pruning + vi.mocked(pollService.fetchAllData) + .mockResolvedValueOnce({ issues: [], pullRequests: [], workflowRuns: [], errors: [], skipped: true }) + // Second call: real data with empty issues — item 555 absent means closed + .mockResolvedValueOnce({ issues: [], pullRequests: [], workflowRuns: [], errors: [] }); + + render(() => ); + + // After the first (skipped) fetch, tracked item must NOT be pruned yet + await waitFor(() => { + expect(viewStore.viewState.trackedItems.length).toBe(1); + }); + + // Trigger a second fetch — non-skipped, sets hasFetchedFresh=true, triggers prune + await capturedFetchAll?.(); + + await waitFor(() => { + expect(viewStore.viewState.trackedItems.length).toBe(0); + }); + }); }); describe("DashboardPage — auth error handling", () => { @@ -830,6 +869,24 @@ describe("DashboardPage — tracked tab", () => { expect(screen.queryByText("Tracked")).toBeNull(); }); + it("Tracked tab badge shows count equal to trackedItems length", async () => { + configStore.updateConfig({ enableTracking: true }); + viewStore.updateViewState({ + trackedItems: [{ + id: 42, + number: 7, + type: "issue" as const, + repoFullName: "owner/repo", + title: "Tracked issue", + addedAt: Date.now(), + }], + }); + render(() => ); + await waitFor(() => { + expect(screen.getByRole("tab", { name: /Tracked/ }).textContent?.replace(/\D+/g, "")).toBe("1"); + }); + }); + it("auto-prunes tracked items absent from open poll data", async () => { render(() => ); configStore.updateConfig({ diff --git a/tests/components/IgnoreBadge.test.tsx b/tests/components/IgnoreBadge.test.tsx index 16772cc4..ba534eea 100644 --- a/tests/components/IgnoreBadge.test.tsx +++ b/tests/components/IgnoreBadge.test.tsx @@ -6,7 +6,7 @@ import type { IgnoredItem } from "../../src/app/stores/view"; function makeIgnoredItem(overrides: Partial = {}): IgnoredItem { return { - id: String(Math.floor(Math.random() * 100000)), + id: Math.floor(Math.random() * 100000), type: "issue", repo: "owner/repo", title: "Test item", @@ -67,8 +67,8 @@ describe("IgnoreBadge", () => { it("popover shows each ignored item with repo and title", async () => { const user = userEvent.setup(); const items = [ - makeIgnoredItem({ id: "1", repo: "owner/repo-a", title: "Issue Alpha" }), - makeIgnoredItem({ id: "2", repo: "owner/repo-b", title: "Issue Beta" }), + makeIgnoredItem({ id: 1, repo: "owner/repo-a", title: "Issue Alpha" }), + makeIgnoredItem({ id: 2, repo: "owner/repo-b", title: "Issue Beta" }), ]; render(() => {}} />); await user.click(getTrigger(2)); @@ -83,7 +83,7 @@ describe("IgnoreBadge", () => { const user = userEvent.setup(); const onUnignore = vi.fn(); const items = [ - makeIgnoredItem({ id: "abc-123", title: "My Issue" }), + makeIgnoredItem({ id: 123, title: "My Issue" }), ]; render(() => ); await user.click(getTrigger(1)); @@ -91,16 +91,16 @@ describe("IgnoreBadge", () => { const unignoreBtn = screen.getByText("Unignore"); await user.click(unignoreBtn); - expect(onUnignore).toHaveBeenCalledWith("abc-123"); + expect(onUnignore).toHaveBeenCalledWith(123); }); it("'Unignore All' calls onUnignore for every item", async () => { const user = userEvent.setup(); const onUnignore = vi.fn(); const items = [ - makeIgnoredItem({ id: "1" }), - makeIgnoredItem({ id: "2" }), - makeIgnoredItem({ id: "3" }), + makeIgnoredItem({ id: 1 }), + makeIgnoredItem({ id: 2 }), + makeIgnoredItem({ id: 3 }), ]; render(() => ); await user.click(getTrigger(3)); @@ -109,9 +109,9 @@ describe("IgnoreBadge", () => { await user.click(unignoreAllBtn); expect(onUnignore).toHaveBeenCalledTimes(3); - expect(onUnignore).toHaveBeenCalledWith("1"); - expect(onUnignore).toHaveBeenCalledWith("2"); - expect(onUnignore).toHaveBeenCalledWith("3"); + expect(onUnignore).toHaveBeenCalledWith(1); + expect(onUnignore).toHaveBeenCalledWith(2); + expect(onUnignore).toHaveBeenCalledWith(3); }); it("clicking backdrop closes popover", async () => { diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 363c89cc..4bc365db 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -42,7 +42,7 @@ describe("IssuesTab", () => { it("filters out ignored issues", () => { const issue = makeIssue({ id: 99, title: "Should be hidden" }); viewStore.ignoreItem({ - id: "99", + id: 99, type: "issue", repo: issue.repoFullName, title: issue.title, @@ -299,7 +299,7 @@ describe("IssuesTab", () => { it("IgnoreBadge renders in the toolbar", () => { const issue = makeIssue({ id: 77, title: "To ignore" }); viewStore.ignoreItem({ - id: "77", + id: 77, type: "issue", repo: issue.repoFullName, title: issue.title, diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index b45b1ee9..dc1dc28d 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -41,7 +41,7 @@ describe("PullRequestsTab", () => { it("filters out ignored PRs", () => { const pr = makePullRequest({ id: 99, title: "Should be hidden" }); viewStore.ignoreItem({ - id: "99", + id: 99, type: "pullRequest", repo: pr.repoFullName, title: pr.title, @@ -402,7 +402,7 @@ describe("PullRequestsTab", () => { it("renders IgnoreBadge in filter toolbar", () => { const pr = makePullRequest({ id: 42, title: "To Ignore", repoFullName: "org/repo-a" }); viewStore.ignoreItem({ - id: "42", + id: 42, type: "pullRequest", repo: pr.repoFullName, title: pr.title, diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index 6d78fd80..835edce8 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -671,7 +671,7 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { const prs = [ makePullRequest({ id: 99, enriched: true, reviewDecision: "REVIEW_REQUIRED", reviewerLogins: ["me"], userLogin: "author" }), ]; - ignoreItem({ id: "99", type: "pullRequest", repo: "org/repo", title: "Ignored PR", ignoredAt: Date.now() }); + ignoreItem({ id: 99, type: "pullRequest", repo: "org/repo", title: "Ignored PR", ignoredAt: Date.now() }); const { container } = renderStrip({ pullRequests: prs }); expect(container.textContent).not.toContain("awaiting review"); @@ -681,7 +681,7 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { const prs = [ makePullRequest({ id: 50, userLogin: "me", draft: false, checkStatus: "failure" }), ]; - ignoreItem({ id: "50", type: "pullRequest", repo: "org/repo", title: "Ignored blocked", ignoredAt: Date.now() }); + ignoreItem({ id: 50, type: "pullRequest", repo: "org/repo", title: "Ignored blocked", ignoredAt: Date.now() }); const { container } = renderStrip({ pullRequests: prs }); expect(container.textContent).not.toContain("blocked"); @@ -691,7 +691,7 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { const issues = [ makeIssue({ id: 200, assigneeLogins: ["me"] }), ]; - ignoreItem({ id: "200", type: "issue", repo: "org/repo", title: "Ignored issue", ignoredAt: Date.now() }); + ignoreItem({ id: 200, type: "issue", repo: "org/repo", title: "Ignored issue", ignoredAt: Date.now() }); const { container } = renderStrip({ issues }); expect(container.innerHTML).toBe(""); @@ -702,7 +702,7 @@ describe("PersonalSummaryStrip — excludes ignored items", () => { makePullRequest({ id: 1, userLogin: "me", draft: false, checkStatus: "failure" }), makePullRequest({ id: 2, userLogin: "me", draft: false, checkStatus: "conflict" }), ]; - ignoreItem({ id: "1", type: "pullRequest", repo: "org/repo", title: "Ignored", ignoredAt: Date.now() }); + ignoreItem({ id: 1, type: "pullRequest", repo: "org/repo", title: "Ignored", ignoredAt: Date.now() }); renderStrip({ pullRequests: prs }); const blockedButton = screen.getByText(/blocked/); diff --git a/tests/components/dashboard/TrackedTab.test.tsx b/tests/components/dashboard/TrackedTab.test.tsx index 68a8e61d..a3e92284 100644 --- a/tests/components/dashboard/TrackedTab.test.tsx +++ b/tests/components/dashboard/TrackedTab.test.tsx @@ -252,21 +252,15 @@ describe("TrackedTab — pin button", () => { }); }); -describe("TrackedTab — ignore", () => { - it("ignoring a tracked item removes it from both lists", () => { +describe("TrackedTab — no ignore action", () => { + it("does not render ignore button on tracked items", () => { const issue = makeIssue({ id: 70, title: "Ignorable Issue" }); const tracked = makeTrackedItem({ id: 70, type: "issue", title: "Ignorable Issue" }); updateViewState({ trackedItems: [tracked] }); render(() => ); - const ignoreBtn = screen.getByLabelText(`Ignore #${issue.number} ${issue.title}`); - fireEvent.click(ignoreBtn); - - // Removed from trackedItems - expect(viewState.trackedItems).toHaveLength(0); - // Added to ignoredItems - expect(viewState.ignoredItems).toHaveLength(1); - expect(viewState.ignoredItems[0].id).toBe("70"); + // Ignore button should NOT be present — TrackedTab only allows untracking + expect(screen.queryByLabelText(`Ignore #${issue.number} ${issue.title}`)).toBeNull(); }); }); diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 200db43b..6f1c0554 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -110,14 +110,14 @@ describe("setSortPreference", () => { describe("ignoreItem / unignoreItem", () => { const item1: IgnoredItem = { - id: "issue-1", + id: 1, type: "issue", repo: "owner/repo", title: "Bug fix", ignoredAt: 1711000000000, }; const item2: IgnoredItem = { - id: "pr-42", + id: 42, type: "pullRequest", repo: "owner/repo", title: "Add feature", @@ -127,7 +127,7 @@ describe("ignoreItem / unignoreItem", () => { it("ignoreItem adds an item to ignoredItems", () => { ignoreItem(item1); expect(viewState.ignoredItems).toHaveLength(1); - expect(viewState.ignoredItems[0].id).toBe("issue-1"); + expect(viewState.ignoredItems[0].id).toBe(1); }); it("ignoreItem does not add duplicates", () => { @@ -145,29 +145,29 @@ describe("ignoreItem / unignoreItem", () => { it("unignoreItem removes the item with the given id", () => { ignoreItem(item1); ignoreItem(item2); - unignoreItem("issue-1"); + unignoreItem(1); expect(viewState.ignoredItems).toHaveLength(1); - expect(viewState.ignoredItems[0].id).toBe("pr-42"); + expect(viewState.ignoredItems[0].id).toBe(42); }); it("unignoreItem is a no-op for an unknown id", () => { ignoreItem(item1); - unignoreItem("does-not-exist"); + unignoreItem(9999); expect(viewState.ignoredItems).toHaveLength(1); }); it("evicts oldest item when at 500 cap (FIFO)", () => { // Fill to 500 for (let i = 0; i < 500; i++) { - ignoreItem({ id: `item-${i}`, type: "issue", repo: "o/r", title: `T${i}`, ignoredAt: 1000 + i }); + ignoreItem({ id: i, type: "issue", repo: "o/r", title: `T${i}`, ignoredAt: 1000 + i }); } expect(viewState.ignoredItems).toHaveLength(500); // Adding 501st should evict item-0 (oldest) - ignoreItem({ id: "item-new", type: "issue", repo: "o/r", title: "New", ignoredAt: 2000 }); + ignoreItem({ id: 9999, type: "issue", repo: "o/r", title: "New", ignoredAt: 2000 }); expect(viewState.ignoredItems).toHaveLength(500); - expect(viewState.ignoredItems[0].id).toBe("item-1"); // item-0 evicted - expect(viewState.ignoredItems[499].id).toBe("item-new"); + expect(viewState.ignoredItems[0].id).toBe(1); // item-0 evicted + expect(viewState.ignoredItems[499].id).toBe(9999); }); }); @@ -177,13 +177,13 @@ describe("pruneStaleIgnoredItems", () => { const old = now - 31 * 24 * 60 * 60 * 1000; const recent = now - 1 * 24 * 60 * 60 * 1000; - ignoreItem({ id: "old-1", type: "issue", repo: "o/r", title: "Old", ignoredAt: old }); - ignoreItem({ id: "recent-1", type: "pullRequest", repo: "o/r", title: "Recent", ignoredAt: recent }); + ignoreItem({ id: 1, type: "issue", repo: "o/r", title: "Old", ignoredAt: old }); + ignoreItem({ id: 2, type: "pullRequest", repo: "o/r", title: "Recent", ignoredAt: recent }); expect(viewState.ignoredItems).toHaveLength(2); pruneStaleIgnoredItems(); expect(viewState.ignoredItems).toHaveLength(1); - expect(viewState.ignoredItems[0].id).toBe("recent-1"); + expect(viewState.ignoredItems[0].id).toBe(2); }); it("is a no-op when ignoredItems is empty", () => { @@ -195,7 +195,7 @@ describe("pruneStaleIgnoredItems", () => { const now = Date.now(); const exactly30 = now - 30 * 24 * 60 * 60 * 1000 + 1000; - ignoreItem({ id: "boundary", type: "issue", repo: "o/r", title: "Edge", ignoredAt: exactly30 }); + ignoreItem({ id: 1, type: "issue", repo: "o/r", title: "Edge", ignoredAt: exactly30 }); pruneStaleIgnoredItems(); expect(viewState.ignoredItems).toHaveLength(1); }); From d9eb643b4d293a9fb6408b72b7c22e071bb83086 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 17:32:14 -0400 Subject: [PATCH 5/5] docs: adds tracked items section to user guide --- docs/USER_GUIDE.md | 24 +++++++++++++++++++++++- tests/components/DashboardPage.test.tsx | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c8222927..1b842518 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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) @@ -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. @@ -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. @@ -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 @@ -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). | --- diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 0ccb480b..11b23ff4 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -144,7 +144,7 @@ beforeEach(async () => { viewStore.resetViewState(); // Reset config store to defaults — prevents enableTracking, selectedRepos, etc. from leaking between tests configStore.resetConfig(); -}); +}, 30_000); describe("DashboardPage — tab switching", () => { it("renders IssuesTab by default", () => {