Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
9 changes: 9 additions & 0 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -162,6 +163,7 @@ async function pollFetch(): Promise<DashboardData> {
pr.enriched = e.enriched;
pr.nodeId = e.nodeId;
pr.surfacedBy = e.surfacedBy;
pr.starCount = e.starCount;
}
} else {
state.pullRequests = data.pullRequests;
Expand Down Expand Up @@ -377,6 +379,13 @@ export default function DashboardPage() {
<div class="pt-14 min-h-[calc(100vh-3.5rem)] flex flex-col">
<div class="max-w-6xl mx-auto w-full bg-base-100 shadow-lg border-x border-base-300 flex-1">
<div class="sticky top-14 z-40 bg-base-100">
<PersonalSummaryStrip
issues={dashboardData.issues}
pullRequests={dashboardData.pullRequests}
workflowRuns={dashboardData.workflowRuns}
userLogin={userLogin()}
onTabChange={handleTabChange}
/>
<TabBar
activeTab={activeTab()}
onTabChange={handleTabChange}
Expand Down
56 changes: 46 additions & 10 deletions src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,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 RoleBadge from "../shared/RoleBadge";
import SkeletonRows from "../shared/SkeletonRows";
import ChevronIcon from "../shared/ChevronIcon";
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
import { deriveInvolvementRoles } from "../../lib/format";
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
import { deriveInvolvementRoles, formatStarCount } from "../../lib/format";
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import RepoLockControls from "../shared/RepoLockControls";
import RepoGitHubLink from "../shared/RepoGitHubLink";
Expand Down Expand Up @@ -75,11 +74,20 @@ export default function IssuesTab(props: IssuesTabProps) {
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<FilterChipGroupDef[]>(() => {
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",
Expand All @@ -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 };
Expand All @@ -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;
}
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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"
/>
</svg>
<p class="text-sm font-medium">No open issues involving you</p>
<p class="text-sm font-medium">
{viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"}
</p>
<p class="text-xs">
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."}
</p>
</div>
}
Expand Down Expand Up @@ -335,6 +362,11 @@ export default function IssuesTab(props: IssuesTabProps) {
<Show when={monitoredRepoNameSet().has(repoGroup.repoFullName)}>
<span class="badge badge-xs badge-ghost" aria-label="monitoring all activity">Monitoring all</span>
</Show>
<Show when={repoGroup.starCount != null && repoGroup.starCount > 0}>
<span class="text-xs text-base-content/50 font-normal" aria-label={`${repoGroup.starCount} stars`}>
★ {formatStarCount(repoGroup.starCount!)}
</span>
</Show>
<Show when={!isExpanded()}>
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
Expand All @@ -359,7 +391,11 @@ export default function IssuesTab(props: IssuesTabProps) {
<div role="list" class="divide-y divide-base-300">
<For each={repoGroup.items}>
{(issue) => (
<div role="listitem">
<div role="listitem" class={
viewState.tabFilters.issues.scope === "all" && isInvolvedItem(issue)
? "border-l-2 border-l-primary"
: undefined
}>
<ItemRow
hideRepo={true}
repo={issue.repoFullName}
Expand Down
182 changes: 182 additions & 0 deletions src/app/components/dashboard/PersonalSummaryStrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { createMemo, For, Show } from "solid-js";
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
import type { TabId } from "../layout/TabBar";
import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view";

interface SummaryCount {
label: string;
count: number;
tab: TabId;
applyFilters: () => 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<string>();
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 (
<Show when={summaryItems().length > 0}>
<div class="flex items-center gap-3 px-4 py-1.5 text-xs border-b border-base-300 bg-base-100">
<For each={summaryItems()}>
{(item, idx) => (
<>
<Show when={idx() > 0}>
<span class="text-base-content/30">·</span>
</Show>
<button
type="button"
class="hover:text-primary transition-colors cursor-pointer"
onClick={() => { item.applyFilters(); props.onTabChange(item.tab); }}
>
<span class="font-medium">{item.count}</span>{" "}{item.label}
</button>
</>
)}
</For>
</div>
</Show>
);
}
Loading