Skip to content

Commit 32ece43

Browse files
authored
feat: adds scope filters, personal summary strip, and star counts (#47)
* feat(ui): adds PersonalSummaryStrip with actionable counts above tab bar * feat(filters): adds scope filter schema and chips with defaultValue * feat(api): adds stargazerCount to GraphQL fragments and data model * feat(ui): adds star counts and involvement border accent to repo groups * feat(filters): implements scope filtering logic in Issues and PRs tabs * feat(api): adds stargazerCount to GraphQL fragments and data model * fix: addresses review findings from Phase 4 and 4.5 * perf: hoists userLoginLower to component-scope createMemo * fix: addresses review findings, summary strip UX, and regression tests - fixes green border bleed: border-primary to border-l-primary - fixes cor-2: prsAwaitingReview excludes user-authored PRs - fixes cor-1: derives effective scope in filter memo - extracts scopeFilterGroup to FilterChips.tsx, removes duplication - moves isInvolvedItem before filteredSortedWithMeta for readability - extracts showScopeFilter createMemo shared by filterGroups and effect - adds contextual labels to summary strip counts - adds click-to-filter with scope=all for unfiltered count semantics - adds blocked checkStatus filter value matching failure+conflict - excludes ignored items from summary strip counts - suppresses reorder highlight animation on filter changes - adds integration tests verifying count-to-filter contract end-to-end - adds regression tests for ignored items, scope reset, empty userLogin * fix: adds draft=ready filter to ready-to-merge summary click * fix: excludes Dep Dashboard from summary, adds missing tests * fix: enables showPrRuns when clicking running actions count * refactor: extracts isUserInvolved, documents approximations * feat(filters): adds mergeable reviewDecision composite value
1 parent 159d953 commit 32ece43

20 files changed

+2060
-82
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export default function ActionsTab(props: ActionsTabProps) {
212212
() => repoGroups().map(g => g.repoFullName),
213213
() => viewState.lockedRepos.actions,
214214
() => viewState.ignoredItems.filter(i => i.type === "workflowRun").length,
215+
() => JSON.stringify(viewState.tabFilters.actions),
215216
);
216217

217218
return (

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import FilterBar from "../layout/FilterBar";
66
import ActionsTab from "./ActionsTab";
77
import IssuesTab from "./IssuesTab";
88
import PullRequestsTab from "./PullRequestsTab";
9+
import PersonalSummaryStrip from "./PersonalSummaryStrip";
910
import { config, setConfig, type TrackedUser } from "../../stores/config";
1011
import { viewState, updateViewState } from "../../stores/view";
1112
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
@@ -162,6 +163,7 @@ async function pollFetch(): Promise<DashboardData> {
162163
pr.enriched = e.enriched;
163164
pr.nodeId = e.nodeId;
164165
pr.surfacedBy = e.surfacedBy;
166+
pr.starCount = e.starCount;
165167
}
166168
} else {
167169
state.pullRequests = data.pullRequests;
@@ -377,6 +379,13 @@ export default function DashboardPage() {
377379
<div class="pt-14 min-h-[calc(100vh-3.5rem)] flex flex-col">
378380
<div class="max-w-6xl mx-auto w-full bg-base-100 shadow-lg border-x border-base-300 flex-1">
379381
<div class="sticky top-14 z-40 bg-base-100">
382+
<PersonalSummaryStrip
383+
issues={dashboardData.issues}
384+
pullRequests={dashboardData.pullRequests}
385+
workflowRuns={dashboardData.workflowRuns}
386+
userLogin={userLogin()}
387+
onTabChange={handleTabChange}
388+
/>
380389
<TabBar
381390
activeTab={activeTab()}
382391
onTabChange={handleTabChange}

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ import IgnoreBadge from "./IgnoreBadge";
88
import SortDropdown from "../shared/SortDropdown";
99
import type { SortOption } from "../shared/SortDropdown";
1010
import PaginationControls from "../shared/PaginationControls";
11-
import FilterChips from "../shared/FilterChips";
12-
import type { FilterChipGroupDef } from "../shared/FilterChips";
11+
import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips";
1312
import RoleBadge from "../shared/RoleBadge";
1413
import SkeletonRows from "../shared/SkeletonRows";
1514
import ChevronIcon from "../shared/ChevronIcon";
1615
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
17-
import { deriveInvolvementRoles } from "../../lib/format";
18-
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
16+
import { deriveInvolvementRoles, formatStarCount } from "../../lib/format";
17+
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping";
1918
import { createReorderHighlight } from "../../lib/reorderHighlight";
2019
import RepoLockControls from "../shared/RepoLockControls";
2120
import RepoGitHubLink from "../shared/RepoGitHubLink";
@@ -75,11 +74,20 @@ export default function IssuesTab(props: IssuesTabProps) {
7574
new Set((props.monitoredRepos ?? []).map(r => r.fullName))
7675
);
7776

77+
const userLoginLower = createMemo(() => props.userLogin.toLowerCase());
78+
79+
const showScopeFilter = createMemo(() =>
80+
(props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1
81+
);
82+
7883
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
7984
const users = props.allUsers;
80-
if (!users || users.length <= 1) return issueFilterGroups;
85+
const base = showScopeFilter()
86+
? [scopeFilterGroup, ...issueFilterGroups]
87+
: [...issueFilterGroups];
88+
if (!users || users.length <= 1) return base;
8189
return [
82-
...issueFilterGroups,
90+
...base,
8391
{
8492
label: "User",
8593
field: "user",
@@ -88,6 +96,16 @@ export default function IssuesTab(props: IssuesTabProps) {
8896
];
8997
});
9098

99+
// Auto-reset scope to default when scope chip is hidden (localStorage hygiene)
100+
createEffect(() => {
101+
if (!showScopeFilter() && viewState.tabFilters.issues.scope !== "involves_me") {
102+
setTabFilter("issues", "scope", "involves_me");
103+
}
104+
});
105+
106+
const isInvolvedItem = (item: Issue) =>
107+
isUserInvolved(item, userLoginLower(), monitoredRepoNameSet());
108+
91109
const sortPref = createMemo(() => {
92110
const pref = viewState.sortPreferences["issues"];
93111
return pref ?? { field: "updatedAt", direction: "desc" as const };
@@ -111,6 +129,10 @@ export default function IssuesTab(props: IssuesTabProps) {
111129

112130
const roles = deriveInvolvementRoles(props.userLogin, issue.userLogin, issue.assigneeLogins, [], upstreamRepoSet().has(issue.repoFullName));
113131

132+
// Scope filter — use effective scope to avoid one-render flash when auto-reset effect hasn't fired yet
133+
const effectiveScope = showScopeFilter() ? tabFilter.scope : "involves_me";
134+
if (effectiveScope === "involves_me" && !isInvolvedItem(issue)) return false;
135+
114136
if (tabFilter.role !== "all") {
115137
if (!roles.includes(tabFilter.role as "author" | "assignee")) return false;
116138
}
@@ -127,7 +149,7 @@ export default function IssuesTab(props: IssuesTabProps) {
127149
if (!monitoredRepoNameSet().has(issue.repoFullName)) {
128150
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user);
129151
if (validUser) {
130-
const surfacedBy = issue.surfacedBy ?? [props.userLogin.toLowerCase()];
152+
const surfacedBy = issue.surfacedBy ?? [userLoginLower()];
131153
if (!surfacedBy.includes(tabFilter.user)) return false;
132154
}
133155
}
@@ -204,6 +226,7 @@ export default function IssuesTab(props: IssuesTabProps) {
204226
() => repoGroups().map(g => g.repoFullName),
205227
() => viewState.lockedRepos.issues,
206228
() => viewState.ignoredItems.filter(i => i.type === "issue").length,
229+
() => JSON.stringify(viewState.tabFilters.issues),
207230
);
208231

209232
function handleSort(field: string, direction: "asc" | "desc") {
@@ -297,9 +320,13 @@ export default function IssuesTab(props: IssuesTabProps) {
297320
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"
298321
/>
299322
</svg>
300-
<p class="text-sm font-medium">No open issues involving you</p>
323+
<p class="text-sm font-medium">
324+
{viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"}
325+
</p>
301326
<p class="text-xs">
302-
Issues where you are the author, assignee, or mentioned will appear here.
327+
{viewState.tabFilters.issues.scope === "all"
328+
? "No issues match your current filters."
329+
: "Issues where you are the author, assignee, or mentioned will appear here."}
303330
</p>
304331
</div>
305332
}
@@ -335,6 +362,11 @@ export default function IssuesTab(props: IssuesTabProps) {
335362
<Show when={monitoredRepoNameSet().has(repoGroup.repoFullName)}>
336363
<span class="badge badge-xs badge-ghost" aria-label="monitoring all activity">Monitoring all</span>
337364
</Show>
365+
<Show when={repoGroup.starCount != null && repoGroup.starCount > 0}>
366+
<span class="text-xs text-base-content/50 font-normal" aria-label={`${repoGroup.starCount} stars`}>
367+
{formatStarCount(repoGroup.starCount!)}
368+
</span>
369+
</Show>
338370
<Show when={!isExpanded()}>
339371
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
340372
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
@@ -359,7 +391,11 @@ export default function IssuesTab(props: IssuesTabProps) {
359391
<div role="list" class="divide-y divide-base-300">
360392
<For each={repoGroup.items}>
361393
{(issue) => (
362-
<div role="listitem">
394+
<div role="listitem" class={
395+
viewState.tabFilters.issues.scope === "all" && isInvolvedItem(issue)
396+
? "border-l-2 border-l-primary"
397+
: undefined
398+
}>
363399
<ItemRow
364400
hideRepo={true}
365401
repo={issue.repoFullName}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { createMemo, For, Show } from "solid-js";
2+
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
3+
import type { TabId } from "../layout/TabBar";
4+
import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view";
5+
6+
interface SummaryCount {
7+
label: string;
8+
count: number;
9+
tab: TabId;
10+
applyFilters: () => void;
11+
}
12+
13+
interface PersonalSummaryStripProps {
14+
issues: Issue[];
15+
pullRequests: PullRequest[];
16+
workflowRuns: WorkflowRun[];
17+
userLogin: string;
18+
onTabChange: (tab: TabId) => void;
19+
}
20+
21+
export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) {
22+
const ignoredIds = createMemo(() => {
23+
const ids = new Set<string>();
24+
for (const item of viewState.ignoredItems) ids.add(item.id);
25+
return ids;
26+
});
27+
28+
// Single-pass over issues to count assigned (excludes ignored + Dep Dashboard)
29+
const issueCounts = createMemo(() => {
30+
const login = props.userLogin.toLowerCase();
31+
if (!login) return { assignedIssues: 0 };
32+
const ignored = ignoredIds();
33+
let assignedIssues = 0;
34+
for (const i of props.issues) {
35+
if (ignored.has(String(i.id))) continue;
36+
if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") continue;
37+
if (i.assigneeLogins.some((a) => a.toLowerCase() === login)) assignedIssues++;
38+
}
39+
return { assignedIssues };
40+
});
41+
42+
// Single-pass over PRs to count awaiting review, ready to merge, and blocked (excludes ignored)
43+
const prCounts = createMemo(() => {
44+
const login = props.userLogin.toLowerCase();
45+
if (!login) return { prsAwaitingReview: 0, prsReadyToMerge: 0, prsBlocked: 0 };
46+
const ignored = ignoredIds();
47+
let prsAwaitingReview = 0;
48+
let prsReadyToMerge = 0;
49+
let prsBlocked = 0;
50+
for (const pr of props.pullRequests) {
51+
if (ignored.has(String(pr.id))) continue;
52+
const isAuthor = pr.userLogin.toLowerCase() === login;
53+
if (
54+
!isAuthor &&
55+
pr.enriched !== false &&
56+
pr.reviewDecision === "REVIEW_REQUIRED" &&
57+
pr.reviewerLogins.some((r) => r.toLowerCase() === login)
58+
) {
59+
prsAwaitingReview++;
60+
}
61+
if (
62+
isAuthor &&
63+
!pr.draft &&
64+
pr.checkStatus === "success" &&
65+
(pr.reviewDecision === "APPROVED" || pr.reviewDecision === null)
66+
) {
67+
prsReadyToMerge++;
68+
}
69+
if (
70+
isAuthor &&
71+
!pr.draft &&
72+
(pr.checkStatus === "failure" || pr.checkStatus === "conflict")
73+
) {
74+
prsBlocked++;
75+
}
76+
}
77+
return { prsAwaitingReview, prsReadyToMerge, prsBlocked };
78+
});
79+
80+
const runningActions = createMemo(() => {
81+
const ignored = ignoredIds();
82+
return props.workflowRuns.filter((r) => !ignored.has(String(r.id)) && r.status === "in_progress").length;
83+
});
84+
85+
const summaryItems = createMemo(() => {
86+
const { assignedIssues } = issueCounts();
87+
const { prsAwaitingReview, prsReadyToMerge, prsBlocked } = prCounts();
88+
const running = runningActions();
89+
const items: SummaryCount[] = [];
90+
// ── Count-to-filter contract ──
91+
// Counts are computed from unfiltered data (ignoring scope, globalFilter, showPrRuns).
92+
// Click filters set scope=all so tabs don't hide items the count included.
93+
// Known approximations (single-value filter system cannot express these):
94+
// - "ready to merge": uses composite reviewDecision=mergeable (APPROVED||null)
95+
// - "awaiting review": count excludes self-authored PRs (!isAuthor), but
96+
// role=reviewer filter includes them if user is both author+reviewer (rare)
97+
// - globalFilter (org/repo) is NOT applied here — counts are persistent
98+
// awareness across all repos, matching the tab badge behavior
99+
// - "running": count includes all in_progress runs; click enables showPrRuns
100+
// so PR-triggered runs are visible in the tab
101+
if (assignedIssues > 0) items.push({
102+
label: assignedIssues === 1 ? "issue assigned" : "issues assigned",
103+
count: assignedIssues,
104+
tab: "issues",
105+
applyFilters: () => {
106+
resetAllTabFilters("issues");
107+
setTabFilter("issues", "scope", "all");
108+
setTabFilter("issues", "role", "assignee");
109+
},
110+
});
111+
if (prsAwaitingReview > 0) items.push({
112+
label: prsAwaitingReview === 1 ? "PR awaiting review" : "PRs awaiting review",
113+
count: prsAwaitingReview,
114+
tab: "pullRequests",
115+
applyFilters: () => {
116+
resetAllTabFilters("pullRequests");
117+
setTabFilter("pullRequests", "scope", "all");
118+
setTabFilter("pullRequests", "role", "reviewer");
119+
setTabFilter("pullRequests", "reviewDecision", "REVIEW_REQUIRED");
120+
},
121+
});
122+
if (prsReadyToMerge > 0) items.push({
123+
label: prsReadyToMerge === 1 ? "PR ready to merge" : "PRs ready to merge",
124+
count: prsReadyToMerge,
125+
tab: "pullRequests",
126+
applyFilters: () => {
127+
resetAllTabFilters("pullRequests");
128+
setTabFilter("pullRequests", "scope", "all");
129+
setTabFilter("pullRequests", "role", "author");
130+
setTabFilter("pullRequests", "draft", "ready");
131+
setTabFilter("pullRequests", "checkStatus", "success");
132+
setTabFilter("pullRequests", "reviewDecision", "mergeable");
133+
},
134+
});
135+
if (prsBlocked > 0) items.push({
136+
label: prsBlocked === 1 ? "PR blocked" : "PRs blocked",
137+
count: prsBlocked,
138+
tab: "pullRequests",
139+
applyFilters: () => {
140+
resetAllTabFilters("pullRequests");
141+
setTabFilter("pullRequests", "scope", "all");
142+
setTabFilter("pullRequests", "role", "author");
143+
setTabFilter("pullRequests", "draft", "ready");
144+
setTabFilter("pullRequests", "checkStatus", "blocked");
145+
},
146+
});
147+
if (running > 0) items.push({
148+
label: running === 1 ? "action running" : "actions running",
149+
count: running,
150+
tab: "actions",
151+
applyFilters: () => {
152+
resetAllTabFilters("actions");
153+
setTabFilter("actions", "conclusion", "running");
154+
updateViewState({ showPrRuns: true });
155+
},
156+
});
157+
return items;
158+
});
159+
160+
return (
161+
<Show when={summaryItems().length > 0}>
162+
<div class="flex items-center gap-3 px-4 py-1.5 text-xs border-b border-base-300 bg-base-100">
163+
<For each={summaryItems()}>
164+
{(item, idx) => (
165+
<>
166+
<Show when={idx() > 0}>
167+
<span class="text-base-content/30">·</span>
168+
</Show>
169+
<button
170+
type="button"
171+
class="hover:text-primary transition-colors cursor-pointer"
172+
onClick={() => { item.applyFilters(); props.onTabChange(item.tab); }}
173+
>
174+
<span class="font-medium">{item.count}</span>{" "}{item.label}
175+
</button>
176+
</>
177+
)}
178+
</For>
179+
</div>
180+
</Show>
181+
);
182+
}

0 commit comments

Comments
 (0)