diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index cdaa88b2..99925d42 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -212,6 +212,7 @@ export default function ActionsTab(props: ActionsTabProps) { () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.actions, () => viewState.ignoredItems.filter(i => i.type === "workflowRun").length, + () => JSON.stringify(viewState.tabFilters.actions), ); return ( diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 9a70498e..6d5b9196 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -6,6 +6,7 @@ import FilterBar from "../layout/FilterBar"; import ActionsTab from "./ActionsTab"; import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; +import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, type TrackedUser } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; @@ -162,6 +163,7 @@ async function pollFetch(): Promise { pr.enriched = e.enriched; pr.nodeId = e.nodeId; pr.surfacedBy = e.surfacedBy; + pr.starCount = e.starCount; } } else { state.pullRequests = data.pullRequests; @@ -377,6 +379,13 @@ export default function DashboardPage() {
+ r.fullName)) ); + const userLoginLower = createMemo(() => props.userLogin.toLowerCase()); + + const showScopeFilter = createMemo(() => + (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 + ); + const filterGroups = createMemo(() => { const users = props.allUsers; - if (!users || users.length <= 1) return issueFilterGroups; + const base = showScopeFilter() + ? [scopeFilterGroup, ...issueFilterGroups] + : [...issueFilterGroups]; + if (!users || users.length <= 1) return base; return [ - ...issueFilterGroups, + ...base, { label: "User", field: "user", @@ -88,6 +96,16 @@ export default function IssuesTab(props: IssuesTabProps) { ]; }); + // Auto-reset scope to default when scope chip is hidden (localStorage hygiene) + createEffect(() => { + if (!showScopeFilter() && viewState.tabFilters.issues.scope !== "involves_me") { + setTabFilter("issues", "scope", "involves_me"); + } + }); + + const isInvolvedItem = (item: Issue) => + isUserInvolved(item, userLoginLower(), monitoredRepoNameSet()); + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["issues"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -111,6 +129,10 @@ export default function IssuesTab(props: IssuesTabProps) { const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName)); + // Scope filter — use effective scope to avoid one-render flash when auto-reset effect hasn't fired yet + const effectiveScope = showScopeFilter() ? tabFilter.scope : "involves_me"; + if (effectiveScope === "involves_me" && !isInvolvedItem(issue)) return false; + if (tabFilter.role !== "all") { if (!roles.includes(tabFilter.role as "author" | "assignee")) return false; } @@ -127,7 +149,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (!monitoredRepoNameSet().has(issue.repoFullName)) { const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user); if (validUser) { - const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()]; + const surfacedBy = issue.surfacedBy ?? [userLoginLower()]; if (!surfacedBy.includes(tabFilter.user)) return false; } } @@ -204,6 +226,7 @@ export default function IssuesTab(props: IssuesTabProps) { () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.issues, () => viewState.ignoredItems.filter(i => i.type === "issue").length, + () => JSON.stringify(viewState.tabFilters.issues), ); function handleSort(field: string, direction: "asc" | "desc") { @@ -297,9 +320,13 @@ export default function IssuesTab(props: IssuesTabProps) { d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> -

No open issues involving you

+

+ {viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"} +

- Issues where you are the author, assignee, or mentioned will appear here. + {viewState.tabFilters.issues.scope === "all" + ? "No issues match your current filters." + : "Issues where you are the author, assignee, or mentioned will appear here."}

} @@ -335,6 +362,11 @@ export default function IssuesTab(props: IssuesTabProps) { Monitoring all + 0}> + + ★ {formatStarCount(repoGroup.starCount!)} + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} @@ -359,7 +391,11 @@ export default function IssuesTab(props: IssuesTabProps) {
{(issue) => ( -
+
void; +} + +interface PersonalSummaryStripProps { + issues: Issue[]; + pullRequests: PullRequest[]; + workflowRuns: WorkflowRun[]; + userLogin: string; + onTabChange: (tab: TabId) => void; +} + +export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { + const ignoredIds = createMemo(() => { + const ids = new Set(); + for (const item of viewState.ignoredItems) ids.add(item.id); + return ids; + }); + + // Single-pass over issues to count assigned (excludes ignored + Dep Dashboard) + const issueCounts = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return { assignedIssues: 0 }; + const ignored = ignoredIds(); + let assignedIssues = 0; + for (const i of props.issues) { + if (ignored.has(String(i.id))) continue; + if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") continue; + if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++; + } + return { assignedIssues }; + }); + + // Single-pass over PRs to count awaiting review, ready to merge, and blocked (excludes ignored) + const prCounts = createMemo(() => { + const login = props.userLogin.toLowerCase(); + if (!login) return { prsAwaitingReview: 0, prsReadyToMerge: 0, prsBlocked: 0 }; + const ignored = ignoredIds(); + let prsAwaitingReview = 0; + let prsReadyToMerge = 0; + let prsBlocked = 0; + for (const pr of props.pullRequests) { + if (ignored.has(String(pr.id))) continue; + const isAuthor = pr.userLogin.toLowerCase() === login; + if ( + !isAuthor && + pr.enriched !== false && + pr.reviewDecision === "REVIEW_REQUIRED" && + pr.reviewerLogins.some((r) => r.toLowerCase() === login) + ) { + prsAwaitingReview++; + } + if ( + isAuthor && + !pr.draft && + pr.checkStatus === "success" && + (pr.reviewDecision === "APPROVED" || pr.reviewDecision === null) + ) { + prsReadyToMerge++; + } + if ( + isAuthor && + !pr.draft && + (pr.checkStatus === "failure" || pr.checkStatus === "conflict") + ) { + prsBlocked++; + } + } + return { prsAwaitingReview, prsReadyToMerge, prsBlocked }; + }); + + const runningActions = createMemo(() => { + const ignored = ignoredIds(); + return props.workflowRuns.filter((r) => !ignored.has(String(r.id)) && r.status === "in_progress").length; + }); + + const summaryItems = createMemo(() => { + const { assignedIssues } = issueCounts(); + const { prsAwaitingReview, prsReadyToMerge, prsBlocked } = prCounts(); + const running = runningActions(); + const items: SummaryCount[] = []; + // ── Count-to-filter contract ── + // Counts are computed from unfiltered data (ignoring scope, globalFilter, showPrRuns). + // Click filters set scope=all so tabs don't hide items the count included. + // Known approximations (single-value filter system cannot express these): + // - "ready to merge": uses composite reviewDecision=mergeable (APPROVED||null) + // - "awaiting review": count excludes self-authored PRs (!isAuthor), but + // role=reviewer filter includes them if user is both author+reviewer (rare) + // - globalFilter (org/repo) is NOT applied here — counts are persistent + // awareness across all repos, matching the tab badge behavior + // - "running": count includes all in_progress runs; click enables showPrRuns + // so PR-triggered runs are visible in the tab + if (assignedIssues > 0) items.push({ + label: assignedIssues === 1 ? "issue assigned" : "issues assigned", + count: assignedIssues, + tab: "issues", + applyFilters: () => { + resetAllTabFilters("issues"); + setTabFilter("issues", "scope", "all"); + setTabFilter("issues", "role", "assignee"); + }, + }); + if (prsAwaitingReview > 0) items.push({ + label: prsAwaitingReview === 1 ? "PR awaiting review" : "PRs awaiting review", + count: prsAwaitingReview, + tab: "pullRequests", + applyFilters: () => { + resetAllTabFilters("pullRequests"); + setTabFilter("pullRequests", "scope", "all"); + setTabFilter("pullRequests", "role", "reviewer"); + setTabFilter("pullRequests", "reviewDecision", "REVIEW_REQUIRED"); + }, + }); + if (prsReadyToMerge > 0) items.push({ + label: prsReadyToMerge === 1 ? "PR ready to merge" : "PRs ready to merge", + count: prsReadyToMerge, + tab: "pullRequests", + applyFilters: () => { + resetAllTabFilters("pullRequests"); + setTabFilter("pullRequests", "scope", "all"); + setTabFilter("pullRequests", "role", "author"); + setTabFilter("pullRequests", "draft", "ready"); + setTabFilter("pullRequests", "checkStatus", "success"); + setTabFilter("pullRequests", "reviewDecision", "mergeable"); + }, + }); + if (prsBlocked > 0) items.push({ + label: prsBlocked === 1 ? "PR blocked" : "PRs blocked", + count: prsBlocked, + tab: "pullRequests", + applyFilters: () => { + resetAllTabFilters("pullRequests"); + setTabFilter("pullRequests", "scope", "all"); + setTabFilter("pullRequests", "role", "author"); + setTabFilter("pullRequests", "draft", "ready"); + setTabFilter("pullRequests", "checkStatus", "blocked"); + }, + }); + if (running > 0) items.push({ + label: running === 1 ? "action running" : "actions running", + count: running, + tab: "actions", + applyFilters: () => { + resetAllTabFilters("actions"); + setTabFilter("actions", "conclusion", "running"); + updateViewState({ showPrRuns: true }); + }, + }); + return items; + }); + + return ( + 0}> +
+ + {(item, idx) => ( + <> + 0}> + · + + + + )} + +
+
+ ); +} diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index f1ae9bb5..51d3ca83 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; -import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; +import { deriveInvolvementRoles, prSizeCategory, formatStarCount } from "../../lib/format"; import { isSafeGitHubUrl } from "../../lib/url"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import ItemRow from "./ItemRow"; @@ -12,14 +12,13 @@ import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; -import FilterChips from "../shared/FilterChips"; -import type { FilterChipGroupDef } from "../shared/FilterChips"; +import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips"; import ReviewBadge from "../shared/ReviewBadge"; import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; @@ -83,6 +82,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ { value: "APPROVED", label: "Approved" }, { value: "CHANGES_REQUESTED", label: "Changes" }, { value: "REVIEW_REQUIRED", label: "Needs review" }, + { value: "mergeable", label: "Mergeable" }, ], }, { @@ -101,6 +101,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ { value: "failure", label: "Failing" }, { value: "pending", label: "Pending" }, { value: "conflict", label: "Conflict" }, + { value: "blocked", label: "Blocked" }, { value: "none", label: "No CI" }, ], }, @@ -143,11 +144,20 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { new Set((props.monitoredRepos ?? []).map(r => r.fullName)) ); + const userLoginLower = createMemo(() => props.userLogin.toLowerCase()); + + const showScopeFilter = createMemo(() => + (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 + ); + const filterGroups = createMemo(() => { const users = props.allUsers; - if (!users || users.length <= 1) return prFilterGroups; + const base = showScopeFilter() + ? [scopeFilterGroup, ...prFilterGroups] + : [...prFilterGroups]; + if (!users || users.length <= 1) return base; return [ - ...prFilterGroups, + ...base, { label: "User", field: "user", @@ -156,6 +166,17 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { ]; }); + // Auto-reset scope to default when scope chip is hidden (localStorage hygiene) + createEffect(() => { + if (!showScopeFilter() && viewState.tabFilters.pullRequests.scope !== "involves_me") { + setTabFilter("pullRequests", "scope", "involves_me"); + } + }); + + const isInvolvedItem = (item: PullRequest) => + isUserInvolved(item, userLoginLower(), monitoredRepoNameSet(), + item.enriched !== false ? item.reviewerLogins : undefined); + const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; return pref ?? { field: "updatedAt", direction: "desc" as const }; @@ -180,6 +201,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const roles = deriveInvolvementRoles(props.userLogin, pr.userLogin, pr.assigneeLogins, pr.reviewerLogins, upstreamRepoSet().has(pr.repoFullName)); const sizeCategory = prSizeCategory(pr.additions, pr.deletions); + // Scope filter — use effective scope to avoid one-render flash when auto-reset effect hasn't fired yet + const effectiveScope = showScopeFilter() ? tabFilters.scope : "involves_me"; + if (effectiveScope === "involves_me" && !isInvolvedItem(pr)) return false; + // Tab filters — light-field filters always apply; heavy-field filters // only apply to enriched PRs so unenriched phase-1 PRs aren't incorrectly hidden const isEnriched = pr.enriched !== false; @@ -189,7 +214,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (!isEnriched && tabFilters.role === "author" && !roles.includes("author")) return false; } if (tabFilters.reviewDecision !== "all") { - if (pr.reviewDecision !== tabFilters.reviewDecision) return false; + if (tabFilters.reviewDecision === "mergeable") { + if (pr.reviewDecision !== "APPROVED" && pr.reviewDecision !== null) return false; + } else { + if (pr.reviewDecision !== tabFilters.reviewDecision) return false; + } } if (tabFilters.draft !== "all") { if (tabFilters.draft === "draft" && !pr.draft) return false; @@ -198,6 +227,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (tabFilters.checkStatus !== "all" && isEnriched) { if (tabFilters.checkStatus === "none") { if (pr.checkStatus !== null) return false; + } else if (tabFilters.checkStatus === "blocked") { + if (pr.checkStatus !== "failure" && pr.checkStatus !== "conflict") return false; } else { if (pr.checkStatus !== tabFilters.checkStatus) return false; } @@ -211,7 +242,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (!monitoredRepoNameSet().has(pr.repoFullName)) { const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user); if (validUser) { - const surfacedBy = pr.surfacedBy ?? [props.userLogin.toLowerCase()]; + const surfacedBy = pr.surfacedBy ?? [userLoginLower()]; if (!surfacedBy.includes(tabFilters.user)) return false; } } @@ -303,6 +334,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.pullRequests, () => viewState.ignoredItems.filter(i => i.type === "pullRequest").length, + () => JSON.stringify(viewState.tabFilters.pullRequests), ); function handleSort(field: string, direction: "asc" | "desc") { @@ -385,9 +417,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { d="M8 7h8m-8 5h5m-5 5h8M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /> -

No open pull requests involving you

+

+ {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} +

- PRs where you are the author, assignee, or reviewer will appear here. + {viewState.tabFilters.pullRequests.scope === "all" + ? "No pull requests match your current filters." + : "PRs where you are the author, assignee, or reviewer will appear here."}

} @@ -436,6 +472,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { Monitoring all + 0}> + + ★ {formatStarCount(repoGroup.starCount!)} + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} @@ -511,7 +552,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
{(pr) => ( -
+
; @@ -16,31 +27,33 @@ interface FilterChipsProps { export default function FilterChips(props: FilterChipsProps) { const hasActiveFilter = () => - props.groups.some((g) => props.values[g.field] !== "all" && props.values[g.field] !== undefined); + props.groups.some((g) => props.values[g.field] !== undefined && props.values[g.field] !== (g.defaultValue ?? "all")); return (
{(group) => { - const current = () => props.values[group.field] ?? "all"; - const isActive = () => current() !== "all"; + const current = () => props.values[group.field] ?? (group.defaultValue ?? "all"); + const isActive = () => current() !== (group.defaultValue ?? "all"); return (
{group.label}:
- + + + {(opt) => (