Skip to content

Commit 08d7fd5

Browse files
authored
feat(dashboard): adds state visibility and repo locking (#37)
* feat(dashboard): adds state visibility and repo locking * refactor(dashboard): uses Show accessor pattern for peek updates * refactor(actions-tab): fixes forward reference to repoGroups * fix(poll): clears shimmer on coordinator destroy + focus a11y * fix(tabs): uses unfiltered repo names for lock pruning * fix(css): adds animate-slow-pulse to reduced-motion block * fix: pre-existing a11y, type cleanup, and naming fixes * test: adds onStart/onEnd and shimmer coverage, fixes guard * fix: addresses all PR review findings for state visibility - cor-1: guards isLast with idx !== -1 in RepoLockControls - cor-2: replaces prevValues.size === 0 with boolean init flag - cor-3: aggregates multi-item peek updates per repo - perf-1: replaces indexOf sort with Map-based index lookup - perf-2: updates prevValues in-place on early-return paths - cq-1: extracts PeekUpdate interface to shared grouping module - cq-2: extracts reorder-highlight logic to createReorderHighlight - cq-4: moves setsEqual to shared collections module - cq-5: fixes JSX indentation in PullRequestsTab collapsed summary - qa-1: adds onEnd test for error/throw path in hot-poll coordinator - qa-2: adds stopPropagation test for unlocked pin button - qa-5: adds WorkflowSummaryCard hotPollingRunIds/flashingRunIds tests - qa-6: adds setsEqual unit tests * fix: replaces fragile split-based peek label with tracked first label * test: removes vacuous cleanup test from reorderHighlight * refactor: extracts flash/peek detection into shared helper - Eliminates ~140 lines of duplicated flash detection logic between tabs - Fixes unbounded prevValues Map leak: full-refresh path prunes stale entries - Adds tab-level shimmer prop threading tests (qa-3) - Adds createFlashDetection unit tests for mass-flash gate and init * refactor: moves PeekUpdate to flashDetection, merges loops * fix(css): increases animation opacity and reverses shimmer direction
1 parent 2220936 commit 08d7fd5

29 files changed

+1454
-158
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@ import { createEffect, createMemo, For, Show } from "solid-js";
22
import { createStore } from "solid-js/store";
33
import type { WorkflowRun } from "../../services/api";
44
import { config } from "../../stores/config";
5-
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type ActionsFilterField } from "../../stores/view";
5+
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view";
66
import WorkflowSummaryCard from "./WorkflowSummaryCard";
77
import IgnoreBadge from "./IgnoreBadge";
88
import SkeletonRows from "../shared/SkeletonRows";
99
import FilterChips from "../shared/FilterChips";
1010
import type { FilterChipGroupDef } from "../shared/FilterChips";
1111
import ChevronIcon from "../shared/ChevronIcon";
1212
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
13+
import RepoLockControls from "../shared/RepoLockControls";
14+
import { orderRepoGroups } from "../../lib/grouping";
15+
import { createReorderHighlight } from "../../lib/reorderHighlight";
16+
import { createFlashDetection } from "../../lib/flashDetection";
1317

1418
interface ActionsTabProps {
1519
workflowRuns: WorkflowRun[];
1620
loading?: boolean;
1721
hasUpstreamRepos?: boolean;
22+
hotPollingRunIds?: ReadonlySet<number>;
1823
}
1924

2025
interface WorkflowGroup {
@@ -134,6 +139,15 @@ export default function ActionsTab(props: ActionsTabProps) {
134139
pruneExpandedRepos("actions", names);
135140
});
136141

142+
const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({
143+
getItems: () => props.workflowRuns,
144+
getHotIds: () => props.hotPollingRunIds,
145+
getExpandedRepos: () => viewState.expandedRepos.actions,
146+
trackKey: (run) => `${run.status}|${run.conclusion}`,
147+
itemLabel: (run) => run.name,
148+
itemStatus: (run) => run.conclusion ?? run.status,
149+
});
150+
137151
function handleIgnore(run: WorkflowRun) {
138152
ignoreItem({
139153
id: String(run.id),
@@ -182,7 +196,20 @@ export default function ActionsTab(props: ActionsTabProps) {
182196
});
183197
});
184198

185-
const repoGroups = createMemo(() => groupRuns(filteredRuns()));
199+
const repoGroups = createMemo(() =>
200+
orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos.actions)
201+
);
202+
203+
createEffect(() => {
204+
const names = activeRepoNames();
205+
if (names.length === 0) return;
206+
pruneLockedRepos("actions", names);
207+
});
208+
209+
const highlightedReposActions = createReorderHighlight(
210+
() => repoGroups().map(g => g.repoFullName),
211+
() => viewState.lockedRepos.actions,
212+
);
186213

187214
return (
188215
<div class="divide-y divide-base-300">
@@ -259,37 +286,49 @@ export default function ActionsTab(props: ActionsTabProps) {
259286
return (
260287
<div class="bg-base-100">
261288
{/* Repo header */}
262-
<button
263-
onClick={() => toggleExpandedRepo("actions", repoGroup.repoFullName)}
264-
aria-expanded={isExpanded()}
265-
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
266-
>
267-
<ChevronIcon size="md" rotated={!isExpanded()} />
268-
{repoGroup.repoFullName}
269-
<Show when={!isExpanded()}>
270-
<span class="ml-auto text-xs font-normal text-base-content/60">
271-
{collapsedSummary().total} workflow{collapsedSummary().total !== 1 ? "s" : ""}
272-
<Show when={collapsedSummary().passed > 0 || collapsedSummary().failed > 0 || collapsedSummary().running > 0}>
273-
{": "}
274-
<Show when={collapsedSummary().passed > 0}>
275-
<span>{collapsedSummary().passed} passed</span>
276-
</Show>
277-
<Show when={collapsedSummary().passed > 0 && (collapsedSummary().failed > 0 || collapsedSummary().running > 0)}>
278-
{", "}
279-
</Show>
280-
<Show when={collapsedSummary().failed > 0}>
281-
<span class="text-error font-medium">{collapsedSummary().failed} failed</span>
289+
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposActions().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
290+
<button
291+
onClick={() => toggleExpandedRepo("actions", repoGroup.repoFullName)}
292+
aria-expanded={isExpanded()}
293+
class="flex-1 flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content"
294+
>
295+
<ChevronIcon size="md" rotated={!isExpanded()} />
296+
{repoGroup.repoFullName}
297+
<Show when={!isExpanded()}>
298+
<span class="ml-auto text-xs font-normal text-base-content/60">
299+
{collapsedSummary().total} workflow{collapsedSummary().total !== 1 ? "s" : ""}
300+
<Show when={collapsedSummary().passed > 0 || collapsedSummary().failed > 0 || collapsedSummary().running > 0}>
301+
{": "}
302+
<Show when={collapsedSummary().passed > 0}>
303+
<span>{collapsedSummary().passed} passed</span>
304+
</Show>
305+
<Show when={collapsedSummary().passed > 0 && (collapsedSummary().failed > 0 || collapsedSummary().running > 0)}>
306+
{", "}
307+
</Show>
308+
<Show when={collapsedSummary().failed > 0}>
309+
<span class="text-error font-medium">{collapsedSummary().failed} failed</span>
310+
</Show>
311+
<Show when={collapsedSummary().failed > 0 && collapsedSummary().running > 0}>
312+
{", "}
313+
</Show>
314+
<Show when={collapsedSummary().running > 0}>
315+
<span>{collapsedSummary().running} running</span>
316+
</Show>
282317
</Show>
283-
<Show when={collapsedSummary().failed > 0 && collapsedSummary().running > 0}>
284-
{", "}
285-
</Show>
286-
<Show when={collapsedSummary().running > 0}>
287-
<span>{collapsedSummary().running} running</span>
288-
</Show>
289-
</Show>
290-
</span>
291-
</Show>
292-
</button>
318+
</span>
319+
</Show>
320+
</button>
321+
<RepoLockControls tab="actions" repoFullName={repoGroup.repoFullName} />
322+
</div>
323+
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
324+
{(peek) => (
325+
<div class="animate-flash flex items-center gap-2 text-xs text-base-content/70 px-4 py-1.5 border-b border-base-300 bg-base-100">
326+
<span class="loading loading-spinner loading-xs text-primary/60" />
327+
<span class="truncate flex-1">{peek().itemLabel}</span>
328+
<span class="badge badge-xs badge-primary">{peek().newStatus}</span>
329+
</div>
330+
)}
331+
</Show>
293332

294333
{/* Workflow cards grid */}
295334
<Show when={isExpanded()}>
@@ -308,6 +347,8 @@ export default function ActionsTab(props: ActionsTabProps) {
308347
onToggle={() => toggleWorkflow(wfKey)}
309348
onIgnoreRun={handleIgnore}
310349
density={config.viewDensity}
350+
hotPollingRunIds={props.hotPollingRunIds}
351+
flashingRunIds={flashingRunIds()}
311352
/>
312353
</div>
313354
);

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
2323
import { getClient, getGraphqlRateLimit } from "../../services/github";
2424
import { formatCount } from "../../lib/format";
25+
import { setsEqual } from "../../lib/collections";
2526

2627
// ── Shared dashboard store (module-level to survive navigation) ─────────────
2728

@@ -221,6 +222,8 @@ const [_coordinator, _setCoordinator] = createSignal<ReturnType<typeof createPol
221222
const [_hotCoordinator, _setHotCoordinator] = createSignal<{ destroy: () => void } | null>(null);
222223

223224
export default function DashboardPage() {
225+
const [hotPollingPRIds, setHotPollingPRIds] = createSignal<ReadonlySet<number>>(new Set());
226+
const [hotPollingRunIds, setHotPollingRunIds] = createSignal<ReadonlySet<number>>(new Set());
224227

225228
const initialTab = createMemo<TabId>(() => {
226229
if (config.rememberLastTab) {
@@ -269,6 +272,16 @@ export default function DashboardPage() {
269272
run.completedAt = update.completedAt;
270273
}
271274
}));
275+
},
276+
{
277+
onStart: (prDbIds, runIds) => {
278+
if (!setsEqual(hotPollingPRIds(), prDbIds)) setHotPollingPRIds(prDbIds);
279+
if (!setsEqual(hotPollingRunIds(), runIds)) setHotPollingRunIds(runIds);
280+
},
281+
onEnd: () => {
282+
if (hotPollingPRIds().size > 0) setHotPollingPRIds(new Set<number>());
283+
if (hotPollingRunIds().size > 0) setHotPollingRunIds(new Set<number>());
284+
},
272285
}
273286
));
274287
}
@@ -355,13 +368,15 @@ export default function DashboardPage() {
355368
userLogin={userLogin()}
356369
allUsers={allUsers()}
357370
trackedUsers={config.trackedUsers}
371+
hotPollingPRIds={hotPollingPRIds()}
358372
/>
359373
</Match>
360374
<Match when={activeTab() === "actions"}>
361375
<ActionsTab
362376
workflowRuns={dashboardData.workflowRuns}
363377
loading={dashboardData.loading}
364378
hasUpstreamRepos={config.upstreamRepos.length > 0}
379+
hotPollingRunIds={hotPollingRunIds()}
365380
/>
366381
</Match>
367382
</Switch>

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
3-
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view";
3+
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
44
import type { Issue } from "../../services/api";
55
import ItemRow from "./ItemRow";
66
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
@@ -15,7 +15,9 @@ import SkeletonRows from "../shared/SkeletonRows";
1515
import ChevronIcon from "../shared/ChevronIcon";
1616
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1717
import { deriveInvolvementRoles } from "../../lib/format";
18-
import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping";
18+
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
19+
import { createReorderHighlight } from "../../lib/reorderHighlight";
20+
import RepoLockControls from "../shared/RepoLockControls";
1921

2022
export interface IssuesTabProps {
2123
issues: Issue[];
@@ -156,7 +158,9 @@ export default function IssuesTab(props: IssuesTabProps) {
156158
const filteredSorted = createMemo(() => filteredSortedWithMeta().items);
157159
const issueMeta = createMemo(() => filteredSortedWithMeta().meta);
158160

159-
const repoGroups = createMemo(() => groupByRepo(filteredSorted()));
161+
const repoGroups = createMemo(() =>
162+
orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos.issues)
163+
);
160164
const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage));
161165
const pageCount = createMemo(() => pageLayout().pageCount);
162166
const pageGroups = createMemo(() =>
@@ -178,6 +182,17 @@ export default function IssuesTab(props: IssuesTabProps) {
178182
pruneExpandedRepos("issues", names);
179183
});
180184

185+
createEffect(() => {
186+
const names = activeRepoNames();
187+
if (names.length === 0) return;
188+
pruneLockedRepos("issues", names);
189+
});
190+
191+
const highlightedReposIssues = createReorderHighlight(
192+
() => repoGroups().map(g => g.repoFullName),
193+
() => viewState.lockedRepos.issues,
194+
);
195+
181196
function handleSort(field: string, direction: "asc" | "desc") {
182197
setSortPreference("issues", field, direction);
183198
setPage(0);
@@ -282,30 +297,33 @@ export default function IssuesTab(props: IssuesTabProps) {
282297

283298
return (
284299
<div class="bg-base-100">
285-
<button
286-
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
287-
aria-expanded={isExpanded()}
288-
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
289-
>
290-
<ChevronIcon size="md" rotated={!isExpanded()} />
291-
{repoGroup.repoFullName}
292-
<Show when={!isExpanded()}>
293-
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
294-
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
295-
<For each={roleSummary()}>
296-
{([role, count]) => (
297-
<span class={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
298-
role === "author" ? "bg-primary/10 text-primary" :
299-
role === "assignee" ? "bg-secondary/10 text-secondary" :
300-
"bg-base-300 text-base-content/70"
301-
}`}>
302-
{role} ×{count}
303-
</span>
304-
)}
305-
</For>
306-
</span>
307-
</Show>
308-
</button>
300+
<div class={`group/repo-header flex items-center bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors duration-300 ${highlightedReposIssues().has(repoGroup.repoFullName) ? "animate-reorder-highlight" : ""}`}>
301+
<button
302+
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
303+
aria-expanded={isExpanded()}
304+
class="flex-1 flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content"
305+
>
306+
<ChevronIcon size="md" rotated={!isExpanded()} />
307+
{repoGroup.repoFullName}
308+
<Show when={!isExpanded()}>
309+
<span class="ml-auto flex items-center gap-2 text-xs font-normal text-base-content/60">
310+
<span>{repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"}</span>
311+
<For each={roleSummary()}>
312+
{([role, count]) => (
313+
<span class={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
314+
role === "author" ? "bg-primary/10 text-primary" :
315+
role === "assignee" ? "bg-secondary/10 text-secondary" :
316+
"bg-base-300 text-base-content/70"
317+
}`}>
318+
{role} ×{count}
319+
</span>
320+
)}
321+
</For>
322+
</span>
323+
</Show>
324+
</button>
325+
<RepoLockControls tab="issues" repoFullName={repoGroup.repoFullName} />
326+
</div>
309327
<Show when={isExpanded()}>
310328
<div role="list" class="divide-y divide-base-300">
311329
<For each={repoGroup.items}>

src/app/components/dashboard/ItemRow.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface ItemRowProps {
1717
commentCount?: number;
1818
hideRepo?: boolean;
1919
surfacedByBadge?: JSX.Element;
20+
isPolling?: boolean;
21+
isFlashing?: boolean;
2022
}
2123

2224
export default function ItemRow(props: ItemRowProps) {
@@ -28,7 +30,8 @@ export default function ItemRow(props: ItemRowProps) {
2830
class={`group relative flex items-start gap-3
2931
hover:bg-base-200
3032
transition-colors
31-
${isCompact() ? "px-4 py-2" : "px-4 py-3"}`}
33+
${isCompact() ? "px-4 py-2" : "px-4 py-3"}
34+
${props.isFlashing ? "animate-flash" : props.isPolling ? "animate-shimmer" : ""}`}
3235
>
3336
{/* Overlay link — covers entire row; interactive children use relative z-10 */}
3437
<Show when={safeUrl()}>
@@ -57,7 +60,7 @@ export default function ItemRow(props: ItemRowProps) {
5760

5861
{/* Main content */}
5962
<div class="flex-1 min-w-0">
60-
<div class={`flex flex-wrap items-baseline gap-x-2 gap-y-0.5 ${isCompact() ? "text-sm" : "text-sm"}`}>
63+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
6164
<span class="text-base-content/60 shrink-0">
6265
#{props.number}
6366
</span>
@@ -100,6 +103,9 @@ export default function ItemRow(props: ItemRowProps) {
100103
<div class="relative z-10">{props.surfacedByBadge}</div>
101104
</Show>
102105
<span title={props.createdAt}>{relativeTime(props.createdAt)}</span>
106+
<Show when={props.isPolling}>
107+
<span class="loading loading-spinner loading-xs text-base-content/40" />
108+
</Show>
103109
<Show when={(props.commentCount ?? 0) > 0}>
104110
<span class="flex items-center gap-0.5">
105111
<svg

0 commit comments

Comments
 (0)