diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 43f2fc46..ab94ab1a 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import type { WorkflowRun } from "../../services/api"; import { config } from "../../stores/config"; -import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type ActionsFilterField } from "../../stores/view"; +import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view"; import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; @@ -10,11 +10,16 @@ import FilterChips from "../shared/FilterChips"; import type { FilterChipGroupDef } from "../shared/FilterChips"; import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; +import RepoLockControls from "../shared/RepoLockControls"; +import { orderRepoGroups } from "../../lib/grouping"; +import { createReorderHighlight } from "../../lib/reorderHighlight"; +import { createFlashDetection } from "../../lib/flashDetection"; interface ActionsTabProps { workflowRuns: WorkflowRun[]; loading?: boolean; hasUpstreamRepos?: boolean; + hotPollingRunIds?: ReadonlySet; } interface WorkflowGroup { @@ -134,6 +139,15 @@ export default function ActionsTab(props: ActionsTabProps) { pruneExpandedRepos("actions", names); }); + const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({ + getItems: () => props.workflowRuns, + getHotIds: () => props.hotPollingRunIds, + getExpandedRepos: () => viewState.expandedRepos.actions, + trackKey: (run) => `${run.status}|${run.conclusion}`, + itemLabel: (run) => run.name, + itemStatus: (run) => run.conclusion ?? run.status, + }); + function handleIgnore(run: WorkflowRun) { ignoreItem({ id: String(run.id), @@ -182,7 +196,20 @@ export default function ActionsTab(props: ActionsTabProps) { }); }); - const repoGroups = createMemo(() => groupRuns(filteredRuns())); + const repoGroups = createMemo(() => + orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos.actions) + ); + + createEffect(() => { + const names = activeRepoNames(); + if (names.length === 0) return; + pruneLockedRepos("actions", names); + }); + + const highlightedReposActions = createReorderHighlight( + () => repoGroups().map(g => g.repoFullName), + () => viewState.lockedRepos.actions, + ); return (
@@ -259,37 +286,49 @@ export default function ActionsTab(props: ActionsTabProps) { return (
{/* Repo header */} - + + + + +
+ + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
{/* Workflow cards grid */} @@ -308,6 +347,8 @@ export default function ActionsTab(props: ActionsTabProps) { onToggle={() => toggleWorkflow(wfKey)} onIgnoreRun={handleIgnore} density={config.viewDensity} + hotPollingRunIds={props.hotPollingRunIds} + flashingRunIds={flashingRunIds()} />
); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 935a7d70..bfc2408a 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -22,6 +22,7 @@ import { import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth"; import { getClient, getGraphqlRateLimit } from "../../services/github"; import { formatCount } from "../../lib/format"; +import { setsEqual } from "../../lib/collections"; // ── Shared dashboard store (module-level to survive navigation) ───────────── @@ -221,6 +222,8 @@ const [_coordinator, _setCoordinator] = createSignal void } | null>(null); export default function DashboardPage() { + const [hotPollingPRIds, setHotPollingPRIds] = createSignal>(new Set()); + const [hotPollingRunIds, setHotPollingRunIds] = createSignal>(new Set()); const initialTab = createMemo(() => { if (config.rememberLastTab) { @@ -269,6 +272,16 @@ export default function DashboardPage() { run.completedAt = update.completedAt; } })); + }, + { + onStart: (prDbIds, runIds) => { + if (!setsEqual(hotPollingPRIds(), prDbIds)) setHotPollingPRIds(prDbIds); + if (!setsEqual(hotPollingRunIds(), runIds)) setHotPollingRunIds(runIds); + }, + onEnd: () => { + if (hotPollingPRIds().size > 0) setHotPollingPRIds(new Set()); + if (hotPollingRunIds().size > 0) setHotPollingRunIds(new Set()); + }, } )); } @@ -355,6 +368,7 @@ export default function DashboardPage() { userLogin={userLogin()} allUsers={allUsers()} trackedUsers={config.trackedUsers} + hotPollingPRIds={hotPollingPRIds()} /> @@ -362,6 +376,7 @@ export default function DashboardPage() { workflowRuns={dashboardData.workflowRuns} loading={dashboardData.loading} hasUpstreamRepos={config.upstreamRepos.length > 0} + hotPollingRunIds={hotPollingRunIds()} /> diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 03b6edd9..77491b87 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; -import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view"; +import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view"; import type { Issue } from "../../services/api"; import ItemRow from "./ItemRow"; import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; @@ -15,7 +15,9 @@ import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import { deriveInvolvementRoles } from "../../lib/format"; -import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping"; +import { createReorderHighlight } from "../../lib/reorderHighlight"; +import RepoLockControls from "../shared/RepoLockControls"; export interface IssuesTabProps { issues: Issue[]; @@ -156,7 +158,9 @@ export default function IssuesTab(props: IssuesTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const issueMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => groupByRepo(filteredSorted())); + const repoGroups = createMemo(() => + orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos.issues) + ); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -178,6 +182,17 @@ export default function IssuesTab(props: IssuesTabProps) { pruneExpandedRepos("issues", names); }); + createEffect(() => { + const names = activeRepoNames(); + if (names.length === 0) return; + pruneLockedRepos("issues", names); + }); + + const highlightedReposIssues = createReorderHighlight( + () => repoGroups().map(g => g.repoFullName), + () => viewState.lockedRepos.issues, + ); + function handleSort(field: string, direction: "asc" | "desc") { setSortPreference("issues", field, direction); setPage(0); @@ -282,30 +297,33 @@ export default function IssuesTab(props: IssuesTabProps) { return (
- +
+ + +
diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index a81fb7dc..9c53bbfa 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -17,6 +17,8 @@ export interface ItemRowProps { commentCount?: number; hideRepo?: boolean; surfacedByBadge?: JSX.Element; + isPolling?: boolean; + isFlashing?: boolean; } export default function ItemRow(props: ItemRowProps) { @@ -28,7 +30,8 @@ export default function ItemRow(props: ItemRowProps) { class={`group relative flex items-start gap-3 hover:bg-base-200 transition-colors - ${isCompact() ? "px-4 py-2" : "px-4 py-3"}`} + ${isCompact() ? "px-4 py-2" : "px-4 py-3"} + ${props.isFlashing ? "animate-flash" : props.isPolling ? "animate-shimmer" : ""}`} > {/* Overlay link — covers entire row; interactive children use relative z-10 */} @@ -57,7 +60,7 @@ export default function ItemRow(props: ItemRowProps) { {/* Main content */}
-
+
#{props.number} @@ -100,6 +103,9 @@ export default function ItemRow(props: ItemRowProps) {
{props.surfacedByBadge}
{relativeTime(props.createdAt)} + + + 0}> ; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size"; @@ -245,7 +249,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const prMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => groupByRepo(filteredSorted())); + const repoGroups = createMemo(() => + orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos.pullRequests) + ); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -267,6 +273,26 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { pruneExpandedRepos("pullRequests", names); }); + createEffect(() => { + const names = activeRepoNames(); + if (names.length === 0) return; + pruneLockedRepos("pullRequests", names); + }); + + const { flashingIds: flashingPRIds, peekUpdates } = createFlashDetection({ + getItems: () => props.pullRequests, + getHotIds: () => props.hotPollingPRIds, + getExpandedRepos: () => viewState.expandedRepos.pullRequests, + trackKey: (pr) => `${pr.checkStatus}|${pr.reviewDecision}`, + itemLabel: (pr) => `#${pr.number} ${pr.title}`, + itemStatus: (pr) => pr.checkStatus ?? pr.reviewDecision ?? "updated", + }); + + const highlightedReposPRs = createReorderHighlight( + () => repoGroups().map(g => g.repoFullName), + () => viewState.lockedRepos.pullRequests, + ); + function handleSort(field: string, direction: "asc" | "desc") { setSortPreference("pullRequests", field, direction); setPage(0); @@ -384,72 +410,84 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return (
- + + 0}> + + {`Approved ×${summaryMeta().reviews.APPROVED}`} + + + 0}> + + {`Changes ×${summaryMeta().reviews.CHANGES_REQUESTED}`} + + + 0}> + + {`Needs review ×${summaryMeta().reviews.REVIEW_REQUIRED}`} + + + + {([role, count]) => ( + + {`${role} ×${count}`} + + )} + + + + + +
+ + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
@@ -475,6 +513,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { /> : undefined } + isPolling={props.hotPollingPRIds?.has(pr.id)} + isFlashing={flashingPRIds().has(pr.id)} >
diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 781f5ca8..b6bc5385 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -8,6 +8,8 @@ interface WorkflowRunRowProps { run: WorkflowRun; onIgnore: (run: WorkflowRun) => void; density: Config["viewDensity"]; + isPolling?: boolean; + isFlashing?: boolean; } function StatusIcon(props: { status: string; conclusion: string | null }) { @@ -19,6 +21,7 @@ function StatusIcon(props: { status: string; conclusion: string | null }) { class="h-4 w-4 text-success shrink-0" viewBox="0 0 20 20" fill="currentColor" + role="img" aria-label="Success" > @@ -168,6 +175,10 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { {relativeTime(props.run.createdAt)} + + + + + } + > + + + + +
+ ); +} diff --git a/src/app/index.css b/src/app/index.css index 40638f85..926851e2 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -17,6 +17,41 @@ animation: slow-pulse 3s ease-in-out infinite; } +/* ── Hot-poll shimmer animation ──────────────────────────────────────────── */ + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@utility animate-shimmer { + background: linear-gradient(90deg, transparent 25%, oklch(from var(--color-base-content) l c h / 0.15) 50%, transparent 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +/* ── Value-change flash animation ────────────────────────────────────────── */ + +@keyframes flash { + 0% { background-color: oklch(from var(--color-primary) l c h / 0.35); } + 100% { background-color: transparent; } +} + +@utility animate-flash { + animation: flash 1s ease-out forwards; +} + +/* ── Repo reorder highlight animation ────────────────────────────────────── */ + +@keyframes reorder-highlight { + 0% { background-color: oklch(from var(--color-warning) l c h / 0.30); } + 100% { background-color: transparent; } +} + +@utility animate-reorder-highlight { + animation: reorder-highlight 1.5s ease-out forwards; +} + /* ── Notification animations ──────────────────────────────────────────────── */ @keyframes toast-slide-in { @@ -92,9 +127,12 @@ } @media (prefers-reduced-motion: reduce) { + .animate-slow-pulse, .animate-toast-in, .animate-toast-out, .animate-drawer-in, .animate-drawer-out, - .animate-overlay-in, .animate-overlay-out { + .animate-overlay-in, .animate-overlay-out, + .animate-shimmer, .animate-flash, .animate-reorder-highlight, + .loading { animation: none; } } diff --git a/src/app/lib/collections.ts b/src/app/lib/collections.ts new file mode 100644 index 00000000..55178e89 --- /dev/null +++ b/src/app/lib/collections.ts @@ -0,0 +1,7 @@ +export function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const item of a) { + if (!b.has(item)) return false; + } + return true; +} diff --git a/src/app/lib/flashDetection.ts b/src/app/lib/flashDetection.ts new file mode 100644 index 00000000..cd227ee8 --- /dev/null +++ b/src/app/lib/flashDetection.ts @@ -0,0 +1,101 @@ +import { createSignal, createEffect, onCleanup, type Accessor } from "solid-js"; + +export interface PeekUpdate { + itemLabel: string; + newStatus: string; +} + +export function createFlashDetection(opts: { + getItems: Accessor; + getHotIds: Accessor | undefined>; + getExpandedRepos: Accessor>; + trackKey: (item: T) => string; + itemLabel: (item: T) => string; + itemStatus: (item: T) => string; +}): { + flashingIds: Accessor>; + peekUpdates: Accessor>; +} { + const [peekUpdates, setPeekUpdates] = createSignal>(new Map()); + let peekTimeout: ReturnType | undefined; + + let prevValues = new Map(); + let initialized = false; + let flashTimeout: ReturnType | undefined; + const [flashingIds, setFlashingIds] = createSignal>(new Set()); + + createEffect(() => { + const items = opts.getItems(); + const hotIds = opts.getHotIds(); + + if (!initialized) { + initialized = true; + prevValues = new Map(items.map(item => [item.id, opts.trackKey(item)])); + return; + } + + // Full refresh path — rebuild map to prune stale entries. + // Items only leave the dataset on full refresh (hotIds empty), not during hot polls. + if (!hotIds || hotIds.size === 0) { + prevValues = new Map(items.map(item => [item.id, opts.trackKey(item)])); + return; + } + + // Single pass: detect changes for hot-polled items + update prevValues + const changed = new Set(); + for (const item of items) { + const key = opts.trackKey(item); + if (hotIds.has(item.id)) { + const prev = prevValues.get(item.id); + if (prev !== undefined && prev !== key) { + changed.add(item.id); + } + } + prevValues.set(item.id, key); + } + + if (changed.size > 0) { + setFlashingIds(prev => new Set([...prev, ...changed])); + clearTimeout(flashTimeout); + flashTimeout = setTimeout(() => setFlashingIds(new Set()), 1000); + + const peeks = new Map(); + const peekCounts = new Map(); + const peekFirstLabels = new Map(); + const expandedRepos = opts.getExpandedRepos(); + for (const item of items) { + if (changed.has(item.id)) { + if (!expandedRepos[item.repoFullName]) { + const count = (peekCounts.get(item.repoFullName) ?? 0) + 1; + peekCounts.set(item.repoFullName, count); + if (count === 1) { + const label = opts.itemLabel(item); + peekFirstLabels.set(item.repoFullName, label); + peeks.set(item.repoFullName, { + itemLabel: label, + newStatus: opts.itemStatus(item), + }); + } else { + peeks.set(item.repoFullName, { + itemLabel: `${peekFirstLabels.get(item.repoFullName)} + ${count - 1} more`, + newStatus: peeks.get(item.repoFullName)!.newStatus, + }); + } + } + } + } + if (peeks.size > 0) { + setPeekUpdates(peeks); + clearTimeout(peekTimeout); + peekTimeout = setTimeout(() => setPeekUpdates(new Map()), 3000); + } + } + }); + + onCleanup(() => { + clearTimeout(flashTimeout); + clearTimeout(peekTimeout); + }); + + return { flashingIds, peekUpdates }; +} diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 6b5ef39d..b6cab553 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -48,3 +48,41 @@ export function slicePageGroups( const end = clampedPage + 1 < boundaries.length ? boundaries[clampedPage + 1] : groups.length; return groups.slice(start, end); } + +export function orderRepoGroups( + groups: G[], + lockedOrder: string[] +): G[] { + const lockedIndex = new Map(lockedOrder.map((name, i) => [name, i])); + const locked: G[] = []; + const unlocked: G[] = []; + + for (const group of groups) { + if (lockedIndex.has(group.repoFullName)) { + locked.push(group); + } else { + unlocked.push(group); + } + } + + locked.sort((a, b) => + (lockedIndex.get(a.repoFullName) ?? 0) - (lockedIndex.get(b.repoFullName) ?? 0) + ); + + return [...locked, ...unlocked]; +} + +export function detectReorderedRepos( + previousOrder: string[], + currentOrder: string[] +): Set { + const moved = new Set(); + const prevIndex = new Map(previousOrder.map((name, i) => [name, i])); + for (let i = 0; i < currentOrder.length; i++) { + const prev = prevIndex.get(currentOrder[i]); + if (prev !== undefined && prev !== i) { + moved.add(currentOrder[i]); + } + } + return moved; +} diff --git a/src/app/lib/reorderHighlight.ts b/src/app/lib/reorderHighlight.ts new file mode 100644 index 00000000..4786fa16 --- /dev/null +++ b/src/app/lib/reorderHighlight.ts @@ -0,0 +1,35 @@ +import { createSignal, createEffect, onCleanup, type Accessor } from "solid-js"; +import { detectReorderedRepos } from "./grouping"; + +export function createReorderHighlight( + getRepoOrder: Accessor, + getLockedOrder: Accessor, +): Accessor> { + let prevOrder: string[] = []; + let prevLocked: string[] = []; + let timeout: ReturnType | undefined; + const [highlighted, setHighlighted] = createSignal>(new Set()); + + createEffect(() => { + const currentOrder = getRepoOrder(); + const currentLocked = getLockedOrder(); + + const lockedChanged = currentLocked.length !== prevLocked.length + || currentLocked.some((r, i) => r !== prevLocked[i]); + + if (prevOrder.length > 0 && !lockedChanged) { + const moved = detectReorderedRepos(prevOrder, currentOrder); + if (moved.size > 0) { + setHighlighted(moved); + clearTimeout(timeout); + timeout = setTimeout(() => setHighlighted(new Set()), 1500); + } + } + + prevOrder = currentOrder; + prevLocked = [...currentLocked]; + }); + onCleanup(() => clearTimeout(timeout)); + + return highlighted; +} diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 46ed6281..82143875 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -4,6 +4,12 @@ import type { TrackedUser } from "../stores/config"; // ── Types ──────────────────────────────────────────────────────────────────── +interface GraphQLRateLimit { + limit: number; + remaining: number; + resetAt: string; +} + export interface OrgEntry { login: string; avatarUrl: string; @@ -267,7 +273,7 @@ interface GraphQLIssueSearchResponse { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: (GraphQLIssueNode | null)[]; }; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } interface GraphQLPRNode { @@ -314,7 +320,7 @@ interface GraphQLPRSearchResponse { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: (GraphQLPRNode | null)[]; }; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } interface ForkCandidate { @@ -329,8 +335,8 @@ interface ForkRepoResult { } interface ForkQueryResponse { - rateLimit?: { limit: number; remaining: number; resetAt: string }; - [key: string]: ForkRepoResult | { limit: number; remaining: number; resetAt: string } | undefined | null; + rateLimit?: GraphQLRateLimit; + [key: string]: ForkRepoResult | GraphQLRateLimit | undefined | null; } // ── GraphQL search query constants ─────────────────────────────────────────── @@ -511,7 +517,7 @@ interface LightPRSearchResponse { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: (GraphQLLightPRNode | null)[]; }; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } /** Phase 2 backfill query: enriches PRs with heavy fields using node IDs. */ @@ -581,7 +587,7 @@ interface HotPRStatusNode { interface HotPRStatusResponse { nodes: (HotPRStatusNode | null)[]; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } interface GraphQLLightPRNode { @@ -643,12 +649,12 @@ interface LightCombinedSearchResponse { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: (GraphQLLightPRNode | null)[]; }; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } interface HeavyBackfillResponse { nodes: (GraphQLHeavyPRNode | null)[]; - rateLimit?: { limit: number; remaining: number; resetAt: string }; + rateLimit?: GraphQLRateLimit; } // Max node IDs per nodes() query (GitHub limit) @@ -667,7 +673,7 @@ interface SearchPageResult { * caller-provided `processNode` callback. Handles partial errors, cap enforcement, * and rate limit tracking. Returns the count of items added by processNode. */ -async function paginateGraphQLSearch; rateLimit?: { limit: number; remaining: number; resetAt: string } }, TNode>( +async function paginateGraphQLSearch; rateLimit?: GraphQLRateLimit }, TNode>( octokit: GitHubOctokit, query: string, queryString: string, @@ -823,7 +829,7 @@ async function runForkPRFallback( try { const forkResponse = await octokit.graphql(forkQuery, variables); - if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string }); + if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as GraphQLRateLimit); for (let i = 0; i < forkChunk.length; i++) { const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined; @@ -1612,7 +1618,7 @@ async function graphqlSearchPRs( try { const forkResponse = await octokit.graphql(forkQuery, variables); - if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { limit: number; remaining: number; resetAt: string }); + if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as GraphQLRateLimit); for (let i = 0; i < forkChunk.length; i++) { const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined; diff --git a/src/app/services/github.ts b/src/app/services/github.ts index af23f1ef..fdeaeb52 100644 --- a/src/app/services/github.ts +++ b/src/app/services/github.ts @@ -34,13 +34,13 @@ export function getGraphqlRateLimit(): RateLimitInfo | null { return _graphqlRateLimit(); } -function safePositiveInt(raw: number | undefined, fallback: number): number { +function safePositiveNumber(raw: number | undefined, fallback: number): number { return raw != null && Number.isFinite(raw) && raw > 0 ? raw : fallback; } export function updateGraphqlRateLimit(rateLimit: { limit: number; remaining: number; resetAt: string }): void { _setGraphqlRateLimit({ - limit: safePositiveInt(rateLimit.limit, _graphqlRateLimit()?.limit ?? 5000), + limit: safePositiveNumber(rateLimit.limit, _graphqlRateLimit()?.limit ?? 5000), remaining: rateLimit.remaining, resetAt: new Date(rateLimit.resetAt), // ISO 8601 string → Date }); @@ -53,7 +53,7 @@ export function updateRateLimitFromHeaders(headers: Record): voi if (remaining !== undefined && reset !== undefined) { const parsedLimit = limit !== undefined ? parseInt(limit, 10) : NaN; _setCoreRateLimit({ - limit: safePositiveInt(parsedLimit, 5000), + limit: safePositiveNumber(parsedLimit, 5000), remaining: parseInt(remaining, 10), resetAt: new Date(parseInt(reset, 10) * 1000), }); diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 42a1355b..1041fc3a 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -556,17 +556,27 @@ export function createHotPollCoordinator( prUpdates: Map, runUpdates: Map, generation: number - ) => void + ) => void, + options?: { + onStart?: (prDbIds: ReadonlySet, runIds: ReadonlySet) => void; + onEnd?: () => void; + } ): { destroy: () => void } { let timeoutId: ReturnType | null = null; let chainGeneration = 0; let consecutiveFailures = 0; + let startedCycle = false; // tracks whether onStart was called for the active chain const MAX_BACKOFF_MULTIPLIER = 8; // caps at 8× the base interval function destroy(): void { // Invalidates any in-flight cycle(); createEffect captures the new value as the next chain's seed chainGeneration++; consecutiveFailures = 0; + // Clear shimmer only if an onStart was active (avoids spurious onEnd on init) + if (startedCycle) { + startedCycle = false; + options?.onEnd?.(); + } if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; @@ -588,12 +598,23 @@ export function createHotPollCoordinator( return; } + // Skip fetch when no authenticated client (e.g., mid-logout) + // Guarded: getClient() can throw during auth state transitions + let client: ReturnType; + try { + client = getClient(); + } catch { + schedule(myGeneration); + return; + } + if (!client) { + schedule(myGeneration); + return; + } + + startedCycle = true; + options?.onStart?.(new Set(_hotPRs.values()), new Set(_hotRuns.keys())); try { - // Skip fetch when no authenticated client (e.g., mid-logout) - if (!getClient()) { - schedule(myGeneration); - return; - } const { prUpdates, runUpdates, generation, hadErrors } = await fetchHotData(); if (myGeneration !== chainGeneration) return; // Chain destroyed during fetch if (hadErrors) { @@ -610,6 +631,11 @@ export function createHotPollCoordinator( consecutiveFailures++; const message = err instanceof Error ? err.message : "Unknown hot-poll error"; pushError("hot-poll", message, true); + } finally { + if (myGeneration === chainGeneration) { + startedCycle = false; + options?.onEnd?.(); + } } schedule(myGeneration); diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 048b6af1..dd65ae76 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -80,11 +80,21 @@ export const ViewStateSchema = z.object({ pullRequests: {}, actions: {}, }), + lockedRepos: z.object({ + issues: z.array(z.string()).default([]), + pullRequests: z.array(z.string()).default([]), + actions: z.array(z.string()).default([]), + }).default({ + issues: [], + pullRequests: [], + actions: [], + }), }); export type ViewState = z.infer; export type IgnoredItem = ViewState["ignoredItems"][number]; export type SortPreference = ViewState["sortPreferences"][string]; +export type LockedReposTab = keyof ViewState["lockedRepos"]; function loadViewState(): ViewState { try { @@ -116,6 +126,7 @@ export function resetViewState(): void { }, showPrRuns: false, expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, + lockedRepos: { issues: [], pullRequests: [], actions: [] }, }); } @@ -267,6 +278,51 @@ export function pruneExpandedRepos( ); } +export function lockRepo(tab: LockedReposTab, repoFullName: string): void { + setViewState(produce((draft) => { + if (!draft.lockedRepos[tab].includes(repoFullName)) { + draft.lockedRepos[tab].push(repoFullName); + } + })); +} + +export function unlockRepo(tab: LockedReposTab, repoFullName: string): void { + setViewState(produce((draft) => { + draft.lockedRepos[tab] = draft.lockedRepos[tab].filter(r => r !== repoFullName); + })); +} + +export function moveLockedRepo( + tab: LockedReposTab, + repoFullName: string, + direction: "up" | "down" +): void { + setViewState(produce((draft) => { + const arr = draft.lockedRepos[tab]; + const idx = arr.indexOf(repoFullName); + 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 pruneLockedRepos( + tab: LockedReposTab, + activeRepoNames: string[] +): void { + const current = untrack(() => viewState.lockedRepos[tab]); + if (current.length === 0) return; + const activeSet = new Set(activeRepoNames); + const filtered = current.filter(name => activeSet.has(name)); + if (filtered.length === current.length) return; + setViewState(produce((draft) => { + draft.lockedRepos[tab] = filtered; + })); +} + export function initViewPersistence(): void { let debounceTimer: ReturnType | undefined; createEffect(() => { diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx index bbc87284..1a2957a6 100644 --- a/tests/components/ActionsTab.test.tsx +++ b/tests/components/ActionsTab.test.tsx @@ -5,7 +5,7 @@ import { createSignal } from "solid-js"; import ActionsTab from "../../src/app/components/dashboard/ActionsTab"; import type { WorkflowRun } from "../../src/app/services/api"; import * as viewStore from "../../src/app/stores/view"; -import { viewState } from "../../src/app/stores/view"; +import { viewState, setAllExpanded } from "../../src/app/stores/view"; import { makeWorkflowRun, resetViewStore } from "../helpers/index"; beforeEach(() => { @@ -435,4 +435,38 @@ describe("ActionsTab", () => { expect(screen.getAllByText("CI").length).toBeGreaterThanOrEqual(1); expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true); }); + + it("passes hotPollingRunIds to workflow summary cards", async () => { + const user = userEvent.setup(); + const runs = [ + makeWorkflowRun({ id: 10, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "in_progress", conclusion: null }), + makeWorkflowRun({ id: 20, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "completed", conclusion: "success" }), + ]; + setAllExpanded("actions", ["org/repo"], true); + const { container } = render(() => ( + + )); + // Click workflow card header to expand and show individual run rows + const ciHeader = screen.getByText("CI"); + await user.click(ciHeader); + // Now WorkflowRunRow elements should be visible with shimmer on the hot-polled run + const runRows = container.querySelectorAll("[class*='flex items-center gap-3']"); + expect(runRows.length).toBeGreaterThanOrEqual(2); + expect(runRows[0]?.classList.contains("animate-shimmer")).toBe(true); + expect(runRows[1]?.classList.contains("animate-shimmer")).toBe(false); + }); + + it("does not apply shimmer when hotPollingRunIds is undefined", async () => { + const user = userEvent.setup(); + const runs = [ + makeWorkflowRun({ id: 1, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "in_progress", conclusion: null }), + ]; + setAllExpanded("actions", ["org/repo"], true); + const { container } = render(() => ); + await user.click(screen.getByText("CI")); + const runRows = container.querySelectorAll("[class*='flex items-center gap-3']"); + for (const row of runRows) { + expect(row.classList.contains("animate-shimmer")).toBe(false); + } + }); }); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 877ccf6f..095f7b91 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -132,4 +132,38 @@ describe("ItemRow", () => { render(() => ); screen.getByText("octocat/Hello-World"); }); + + it("applies shimmer class when isPolling is true", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(true); + expect(container.querySelector(".loading-spinner")).toBeTruthy(); + }); + + it("does not apply shimmer class when isPolling is false", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + expect(container.querySelector(".loading-spinner")).toBeFalsy(); + }); + + it("does not apply shimmer class when isPolling is omitted", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + expect(container.querySelector(".loading-spinner")).toBeFalsy(); + }); + + it("applies flash class when isFlashing is true", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(true); + }); + + it("does not apply flash class when isFlashing is false", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(false); + }); + + it("flash takes precedence over shimmer", () => { + const { container } = render(() => ); + expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(true); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + }); }); diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index 73490298..f52f9cf5 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -627,4 +627,33 @@ describe("PullRequestsTab", () => { screen.getByText("org/repo-b"); screen.getByText("Repo B PR 0"); }); + + it("applies shimmer class to rows whose IDs are in hotPollingPRIds", () => { + const prs = [ + makePullRequest({ id: 42, number: 42, title: "Hot PR", repoFullName: "org/repo" }), + makePullRequest({ id: 99, number: 99, title: "Cold PR", repoFullName: "org/repo" }), + ]; + setAllExpanded("pullRequests", ["org/repo"], true); + const { container } = render(() => ( + + )); + const rows = container.querySelectorAll("[role='listitem']"); + expect(rows.length).toBe(2); + // First row (id=42, hot-polled) should have shimmer + expect(rows[0]?.querySelector(".animate-shimmer")).toBeTruthy(); + // Second row (id=99, not hot-polled) should not + expect(rows[1]?.querySelector(".animate-shimmer")).toBeFalsy(); + }); + + it("does not apply shimmer when hotPollingPRIds is undefined", () => { + const prs = [ + makePullRequest({ id: 1, number: 1, title: "Normal PR", repoFullName: "org/repo" }), + ]; + setAllExpanded("pullRequests", ["org/repo"], true); + const { container } = render(() => ( + + )); + const rows = container.querySelectorAll("[role='listitem']"); + expect(rows[0]?.querySelector(".animate-shimmer")).toBeFalsy(); + }); }); diff --git a/tests/components/WorkflowRunRow.test.tsx b/tests/components/WorkflowRunRow.test.tsx index fef3e43b..0e1c605c 100644 --- a/tests/components/WorkflowRunRow.test.tsx +++ b/tests/components/WorkflowRunRow.test.tsx @@ -127,4 +127,43 @@ describe("WorkflowRunRow", () => { expect(row?.className).toContain("py-2.5"); expect(row?.className).toContain("px-4"); }); + + it("applies shimmer class when isPolling is true", () => { + const { container } = render(() => ( + {}} density="comfortable" isPolling={true} /> + )); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(true); + expect(container.querySelector(".loading-spinner")).toBeTruthy(); + }); + + it("does not apply shimmer when isPolling is false", () => { + const { container } = render(() => ( + {}} density="comfortable" isPolling={false} /> + )); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + expect(container.querySelector(".loading-spinner")).toBeFalsy(); + }); + + it("does not apply shimmer when isPolling is omitted", () => { + const { container } = render(() => ( + {}} density="comfortable" /> + )); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + expect(container.querySelector(".loading-spinner")).toBeFalsy(); + }); + + it("applies flash class when isFlashing is true", () => { + const { container } = render(() => ( + {}} density="comfortable" isFlashing={true} /> + )); + expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(true); + }); + + it("flash takes precedence over shimmer", () => { + const { container } = render(() => ( + {}} density="comfortable" isFlashing={true} isPolling={true} /> + )); + expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(true); + expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); + }); }); diff --git a/tests/components/WorkflowSummaryCard.test.tsx b/tests/components/WorkflowSummaryCard.test.tsx index 6d684280..b2331ec7 100644 --- a/tests/components/WorkflowSummaryCard.test.tsx +++ b/tests/components/WorkflowSummaryCard.test.tsx @@ -175,4 +175,52 @@ describe("WorkflowSummaryCard", () => { const card = container.firstElementChild as HTMLElement; expect(card.className).toContain("border-l-warning"); }); + + it("passes isPolling to WorkflowRunRow when hotPollingRunIds contains run ID", () => { + const runs = [ + makeWorkflowRun({ id: 10, conclusion: null, status: "in_progress" }), + makeWorkflowRun({ id: 20, conclusion: "success", status: "completed" }), + ]; + const hotPollingRunIds = new Set([10]); + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + hotPollingRunIds={hotPollingRunIds} + /> + )); + const rows = container.querySelectorAll("[class*='flex items-center gap-3']"); + // First row (id=10, in hot poll set) should have shimmer + expect(rows[0]?.classList.contains("animate-shimmer")).toBe(true); + // Second row (id=20, not in hot poll set) should not + expect(rows[1]?.classList.contains("animate-shimmer")).toBe(false); + }); + + it("passes isFlashing to WorkflowRunRow when flashingRunIds contains run ID", () => { + const runs = [ + makeWorkflowRun({ id: 10, conclusion: "success", status: "completed" }), + makeWorkflowRun({ id: 20, conclusion: "success", status: "completed" }), + ]; + const flashingRunIds = new Set([20]); + const { container } = render(() => ( + {}} + onIgnoreRun={() => {}} + density="comfortable" + flashingRunIds={flashingRunIds} + /> + )); + const rows = container.querySelectorAll("[class*='flex items-center gap-3']"); + // First row (id=10, not flashing) should not have flash + expect(rows[0]?.classList.contains("animate-flash")).toBe(false); + // Second row (id=20, flashing) should have flash + expect(rows[1]?.classList.contains("animate-flash")).toBe(true); + }); }); diff --git a/tests/components/shared/RepoLockControls.test.tsx b/tests/components/shared/RepoLockControls.test.tsx new file mode 100644 index 00000000..2d74540a --- /dev/null +++ b/tests/components/shared/RepoLockControls.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import RepoLockControls from "../../../src/app/components/shared/RepoLockControls"; +import { resetViewState, viewState, lockRepo } from "../../../src/app/stores/view"; + +beforeEach(() => { + resetViewState(); +}); + +describe("RepoLockControls", () => { + it("renders unlock (pin) icon when repo is not locked", () => { + render(() => ( + + )); + expect(screen.getByLabelText("Pin owner/repo to top of list")).toBeTruthy(); + }); + + it("renders lock icon + chevrons when repo IS locked", () => { + lockRepo("issues", "owner/repo"); + render(() => ( + + )); + expect(screen.getByLabelText("Unpin owner/repo")).toBeTruthy(); + expect(screen.getByLabelText("Move owner/repo up")).toBeTruthy(); + expect(screen.getByLabelText("Move owner/repo down")).toBeTruthy(); + }); + + it("click pin icon → locks the repo", () => { + render(() => ( + + )); + fireEvent.click(screen.getByLabelText("Pin owner/repo to top of list")); + expect(viewState.lockedRepos.issues).toContain("owner/repo"); + }); + + it("click lock icon → unlocks the repo", () => { + lockRepo("issues", "owner/repo"); + render(() => ( + + )); + fireEvent.click(screen.getByLabelText("Unpin owner/repo")); + expect(viewState.lockedRepos.issues).not.toContain("owner/repo"); + }); + + it("up button moves repo up in order", () => { + lockRepo("issues", "owner/a"); + lockRepo("issues", "owner/b"); + render(() => ( + + )); + fireEvent.click(screen.getByLabelText("Move owner/b up")); + expect(viewState.lockedRepos.issues[0]).toBe("owner/b"); + expect(viewState.lockedRepos.issues[1]).toBe("owner/a"); + }); + + it("down button moves repo down in order", () => { + lockRepo("issues", "owner/a"); + lockRepo("issues", "owner/b"); + render(() => ( + + )); + fireEvent.click(screen.getByLabelText("Move owner/a down")); + expect(viewState.lockedRepos.issues[0]).toBe("owner/b"); + expect(viewState.lockedRepos.issues[1]).toBe("owner/a"); + }); + + it("up button is disabled when repo is first in locked list", () => { + lockRepo("issues", "owner/repo"); + render(() => ( + + )); + const upBtn = screen.getByLabelText("Move owner/repo up") as HTMLButtonElement; + expect(upBtn.disabled).toBe(true); + }); + + it("down button is disabled when repo is last in locked list", () => { + lockRepo("issues", "owner/repo"); + render(() => ( + + )); + const downBtn = screen.getByLabelText("Move owner/repo down") as HTMLButtonElement; + expect(downBtn.disabled).toBe(true); + }); + + it("stopPropagation — parent click NOT triggered on locked button click", () => { + lockRepo("issues", "owner/repo"); + const parentClick = vi.fn(); + render(() => ( +
+ +
+ )); + fireEvent.click(screen.getByLabelText("Unpin owner/repo")); + expect(parentClick).not.toHaveBeenCalled(); + }); + + it("stopPropagation — parent click NOT triggered on pin button click", () => { + const parentClick = vi.fn(); + render(() => ( +
+ +
+ )); + fireEvent.click(screen.getByLabelText("Pin owner/repo to top of list")); + expect(parentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/collections.test.ts b/tests/lib/collections.test.ts new file mode 100644 index 00000000..5d5b4faf --- /dev/null +++ b/tests/lib/collections.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { setsEqual } from "../../src/app/lib/collections"; + +describe("setsEqual", () => { + it("returns true for identical sets", () => { + expect(setsEqual(new Set([1, 2, 3]), new Set([1, 2, 3]))).toBe(true); + }); + + it("returns true for two empty sets", () => { + expect(setsEqual(new Set(), new Set())).toBe(true); + }); + + it("returns false for different sizes", () => { + expect(setsEqual(new Set([1, 2]), new Set([1]))).toBe(false); + }); + + it("returns false for same size but different elements", () => { + expect(setsEqual(new Set([1, 2]), new Set([2, 3]))).toBe(false); + }); + + it("works with string sets", () => { + expect(setsEqual(new Set(["a", "b"]), new Set(["b", "a"]))).toBe(true); + expect(setsEqual(new Set(["a"]), new Set(["b"]))).toBe(false); + }); +}); diff --git a/tests/lib/flashDetection.test.ts b/tests/lib/flashDetection.test.ts new file mode 100644 index 00000000..9c15aa2f --- /dev/null +++ b/tests/lib/flashDetection.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { createRoot, createSignal } from "solid-js"; +import { createFlashDetection } from "../../src/app/lib/flashDetection"; + +interface MockItem { + id: number; + repoFullName: string; + status: string; +} + +describe("createFlashDetection", () => { + it("returns empty flashingIds and peekUpdates on initialization", () => { + createRoot((dispose) => { + const items: MockItem[] = [ + { id: 1, repoFullName: "org/repo", status: "pending" }, + ]; + const { flashingIds, peekUpdates } = createFlashDetection({ + getItems: () => items, + getHotIds: () => undefined, + getExpandedRepos: () => ({}), + trackKey: (item) => item.status, + itemLabel: (item) => `Item ${item.id}`, + itemStatus: (item) => item.status, + }); + + expect(flashingIds()).toBeInstanceOf(Set); + expect(flashingIds().size).toBe(0); + expect(peekUpdates()).toBeInstanceOf(Map); + expect(peekUpdates().size).toBe(0); + + dispose(); + }); + }); + + it("does not flash when hotIds is empty (mass-flash gate)", () => { + createRoot((dispose) => { + const [items, setItems] = createSignal([ + { id: 1, repoFullName: "org/repo", status: "pending" }, + ]); + const { flashingIds } = createFlashDetection({ + getItems: items, + getHotIds: () => new Set(), + getExpandedRepos: () => ({}), + trackKey: (item) => item.status, + itemLabel: (item) => `Item ${item.id}`, + itemStatus: (item) => item.status, + }); + + // Change status without hot IDs — should not flash + setItems([{ id: 1, repoFullName: "org/repo", status: "success" }]); + expect(flashingIds().size).toBe(0); + + dispose(); + }); + }); + + it("does not flash when hotIds is undefined", () => { + createRoot((dispose) => { + const [items, setItems] = createSignal([ + { id: 1, repoFullName: "org/repo", status: "pending" }, + ]); + const { flashingIds } = createFlashDetection({ + getItems: items, + getHotIds: () => undefined, + getExpandedRepos: () => ({}), + trackKey: (item) => item.status, + itemLabel: (item) => `Item ${item.id}`, + itemStatus: (item) => item.status, + }); + + setItems([{ id: 1, repoFullName: "org/repo", status: "success" }]); + expect(flashingIds().size).toBe(0); + + dispose(); + }); + }); + + it("prunes stale entries on full-refresh path", () => { + createRoot((dispose) => { + const [items, setItems] = createSignal([ + { id: 1, repoFullName: "org/repo", status: "pending" }, + { id: 2, repoFullName: "org/repo", status: "success" }, + ]); + const { flashingIds } = createFlashDetection({ + getItems: items, + getHotIds: () => undefined, + getExpandedRepos: () => ({}), + trackKey: (item) => item.status, + itemLabel: (item) => `Item ${item.id}`, + itemStatus: (item) => item.status, + }); + + // Remove item 2 (simulates PR closed on full refresh) + setItems([{ id: 1, repoFullName: "org/repo", status: "pending" }]); + + // No crash, no flash — stale entry for id=2 was pruned + expect(flashingIds().size).toBe(0); + + dispose(); + }); + }); +}); diff --git a/tests/lib/grouping-lock.test.ts b/tests/lib/grouping-lock.test.ts new file mode 100644 index 00000000..78088431 --- /dev/null +++ b/tests/lib/grouping-lock.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { orderRepoGroups, detectReorderedRepos } from "../../src/app/lib/grouping"; + +describe("orderRepoGroups", () => { + it("places locked repos first in locked order", () => { + const groups = [ + { repoFullName: "org/c", items: [] }, + { repoFullName: "org/a", items: [] }, + { repoFullName: "org/b", items: [] }, + ]; + const result = orderRepoGroups(groups, ["org/b", "org/a"]); + expect(result.map(g => g.repoFullName)).toEqual(["org/b", "org/a", "org/c"]); + }); + + it("returns original order with empty locked array", () => { + const groups = [ + { repoFullName: "org/c", items: [] }, + { repoFullName: "org/a", items: [] }, + ]; + const result = orderRepoGroups(groups, []); + expect(result.map(g => g.repoFullName)).toEqual(["org/c", "org/a"]); + }); + + it("ignores stale locked names not in groups", () => { + const groups = [ + { repoFullName: "org/a", items: [] }, + { repoFullName: "org/b", items: [] }, + ]; + const result = orderRepoGroups(groups, ["org/z", "org/a"]); + expect(result.map(g => g.repoFullName)).toEqual(["org/a", "org/b"]); + }); + + it("works with objects that have extra fields", () => { + const groups = [ + { repoFullName: "org/a", workflows: [{ id: 1 }] }, + { repoFullName: "org/b", workflows: [] }, + ]; + const result = orderRepoGroups(groups, ["org/b"]); + expect(result.map(g => g.repoFullName)).toEqual(["org/b", "org/a"]); + expect(result[1]).toHaveProperty("workflows"); + }); +}); + +describe("detectReorderedRepos", () => { + it("detects moved repos", () => { + const prev = ["org/a", "org/b", "org/c"]; + const curr = ["org/c", "org/a", "org/b"]; + const moved = detectReorderedRepos(prev, curr); + expect(moved).toEqual(new Set(["org/a", "org/b", "org/c"])); + }); + + it("returns empty set when order unchanged", () => { + const order = ["org/a", "org/b"]; + expect(detectReorderedRepos(order, order)).toEqual(new Set()); + }); + + it("ignores new repos not in previous", () => { + const prev = ["org/a"]; + const curr = ["org/a", "org/b"]; + expect(detectReorderedRepos(prev, curr)).toEqual(new Set()); + }); + + it("ignores removed repos", () => { + const prev = ["org/a", "org/b"]; + const curr = ["org/b"]; + // org/b was at index 1, now at index 0 -> moved + expect(detectReorderedRepos(prev, curr)).toEqual(new Set(["org/b"])); + }); +}); diff --git a/tests/lib/reorderHighlight.test.ts b/tests/lib/reorderHighlight.test.ts new file mode 100644 index 00000000..9b3b6c50 --- /dev/null +++ b/tests/lib/reorderHighlight.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { createRoot, createSignal } from "solid-js"; +import { createReorderHighlight } from "../../src/app/lib/reorderHighlight"; + +describe("createReorderHighlight", () => { + it("returns an accessor that starts as empty set", () => { + createRoot((dispose) => { + const [order] = createSignal(["a", "b"]); + const [locked] = createSignal([]); + const highlighted = createReorderHighlight(order, locked); + + expect(highlighted()).toBeInstanceOf(Set); + expect(highlighted().size).toBe(0); + + dispose(); + }); + }); + + it("does not highlight on first render (initialization)", () => { + createRoot((dispose) => { + const [order] = createSignal(["a", "b", "c"]); + const [locked] = createSignal([]); + const highlighted = createReorderHighlight(order, locked); + + // First render seeds prevOrder — no detection + expect(highlighted().size).toBe(0); + dispose(); + }); + }); + +}); diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts index ddeb9f05..84cde603 100644 --- a/tests/services/hot-poll.test.ts +++ b/tests/services/hot-poll.test.ts @@ -631,9 +631,9 @@ describe("createHotPollCoordinator", () => { }); }); - it("calls pushError when cycle throws", async () => { + it("silently reschedules when getClient throws", async () => { const onHotData = vi.fn(); - // Make getClient() throw (now inside the try block) to trigger the catch path + // getClient() throw is caught by the pre-onStart guard — schedules next cycle without pushError mockGetClient.mockImplementation(() => { throw new Error("auth crash"); }); rebuildHotSets({ @@ -647,12 +647,120 @@ describe("createHotPollCoordinator", () => { await createRoot(async (dispose) => { createHotPollCoordinator(() => 10, onHotData); await vi.advanceTimersByTimeAsync(10_000); - expect(pushError).toHaveBeenCalledWith("hot-poll", "auth crash", true); + expect(pushError).not.toHaveBeenCalled(); expect(onHotData).not.toHaveBeenCalled(); dispose(); }); }); + it("calls onStart with correct IDs and onEnd after cycle", async () => { + const onHotData = vi.fn(); + const onStart = vi.fn(); + const onEnd = vi.fn(); + mockGetClient.mockReturnValue(makeOctokit()); + + rebuildHotSets({ + ...emptyData, + pullRequests: [makePullRequest({ id: 42, nodeId: "PR_node42", repoFullName: "o/r" })], + workflowRuns: [makeWorkflowRun({ id: 7, status: "in_progress", conclusion: null, repoFullName: "o/r" })], + }); + + await createRoot(async (dispose) => { + createHotPollCoordinator(() => 10, onHotData, { onStart, onEnd }); + await vi.advanceTimersByTimeAsync(10_000); + expect(onStart).toHaveBeenCalledTimes(1); + const [prIds, runIds] = onStart.mock.calls[0]; + expect(prIds).toBeInstanceOf(Set); + expect(prIds.has(42)).toBe(true); + expect(runIds).toBeInstanceOf(Set); + expect(runIds.has(7)).toBe(true); + expect(onEnd).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + it("does not call onStart when hot sets are empty", async () => { + const onHotData = vi.fn(); + const onStart = vi.fn(); + const onEnd = vi.fn(); + mockGetClient.mockReturnValue(makeOctokit()); + + clearHotSets(); + + await createRoot(async (dispose) => { + createHotPollCoordinator(() => 10, onHotData, { onStart, onEnd }); + await vi.advanceTimersByTimeAsync(10_000); + expect(onStart).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + dispose(); + }); + }); + + it("calls onEnd on destroy when a cycle was active", async () => { + const onHotData = vi.fn(); + const onStart = vi.fn(); + const onEnd = vi.fn(); + mockGetClient.mockReturnValue(makeOctokit()); + + rebuildHotSets({ + ...emptyData, + workflowRuns: [makeWorkflowRun({ id: 1, status: "in_progress", conclusion: null, repoFullName: "o/r" })], + }); + + await createRoot(async (dispose) => { + createHotPollCoordinator(() => 10, onHotData, { onStart, onEnd }); + // Let one cycle start + await vi.advanceTimersByTimeAsync(10_000); + expect(onStart).toHaveBeenCalledTimes(1); + // onEnd fires from the completed cycle's finally block + expect(onEnd).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + it("does not call onEnd on destroy when no cycle ran", async () => { + const onHotData = vi.fn(); + const onEnd = vi.fn(); + mockGetClient.mockReturnValue(makeOctokit()); + + // No hot items → cycle will skip (no onStart) + clearHotSets(); + + await createRoot(async (dispose) => { + const coordinator = createHotPollCoordinator(() => 10, onHotData, { onEnd }); + coordinator.destroy(); + expect(onEnd).not.toHaveBeenCalled(); + dispose(); + }); + }); + + it("calls onEnd on error/throw path", async () => { + const onHotData = vi.fn(); + const onStart = vi.fn(); + const onEnd = vi.fn(); + // getClient() returns a valid client so onStart fires, but fetchHotData rejects + const octokit = makeOctokit(); + mockGetClient.mockReturnValue(octokit); + // Make the graphql call throw (fetchHotData will throw) + octokit.graphql = vi.fn().mockRejectedValue(new Error("network failure")); + + rebuildHotSets({ + ...emptyData, + pullRequests: [makePullRequest({ id: 42, nodeId: "PR_node42", repoFullName: "o/r" })], + }); + + const { pushError } = await import("../../src/app/lib/errors"); + (pushError as ReturnType).mockClear(); + + await createRoot(async (dispose) => { + createHotPollCoordinator(() => 10, onHotData, { onStart, onEnd }); + await vi.advanceTimersByTimeAsync(10_000); + expect(onStart).toHaveBeenCalledTimes(1); + expect(onEnd).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + it("does not schedule when interval is 0", async () => { const onHotData = vi.fn(); mockGetClient.mockReturnValue(makeOctokit()); diff --git a/tests/stores/view-lock.test.ts b/tests/stores/view-lock.test.ts new file mode 100644 index 00000000..3ea5fc70 --- /dev/null +++ b/tests/stores/view-lock.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + viewState, + resetViewState, + lockRepo, + unlockRepo, + moveLockedRepo, + pruneLockedRepos, + ViewStateSchema, +} from "../../src/app/stores/view"; + +describe("view lock store", () => { + beforeEach(() => { + resetViewState(); + }); + + describe("lockRepo", () => { + it("locks a repo", () => { + lockRepo("issues", "org/repo-a"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a"]); + }); + + it("appends to end", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a", "org/repo-b"]); + }); + + it("deduplicates", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-a"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a"]); + }); + + it("locks per-tab independently", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("pullRequests", "org/repo-b"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a"]); + expect(viewState.lockedRepos.pullRequests).toEqual(["org/repo-b"]); + expect(viewState.lockedRepos.actions).toEqual([]); + }); + }); + + describe("unlockRepo", () => { + it("removes from locked array", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + unlockRepo("issues", "org/repo-a"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-b"]); + }); + + it("no-op if not locked", () => { + unlockRepo("issues", "org/repo-a"); + expect(viewState.lockedRepos.issues).toEqual([]); + }); + }); + + describe("moveLockedRepo", () => { + it("swaps with neighbor up", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + lockRepo("issues", "org/repo-c"); + moveLockedRepo("issues", "org/repo-b", "up"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-b", "org/repo-a", "org/repo-c"]); + }); + + it("swaps with neighbor down", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + lockRepo("issues", "org/repo-c"); + moveLockedRepo("issues", "org/repo-b", "down"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a", "org/repo-c", "org/repo-b"]); + }); + + it("no-op at top boundary", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + moveLockedRepo("issues", "org/repo-a", "up"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a", "org/repo-b"]); + }); + + it("no-op at bottom boundary", () => { + lockRepo("issues", "org/repo-a"); + lockRepo("issues", "org/repo-b"); + moveLockedRepo("issues", "org/repo-b", "down"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a", "org/repo-b"]); + }); + + it("no-op if not locked", () => { + lockRepo("issues", "org/repo-a"); + moveLockedRepo("issues", "org/repo-z", "up"); + expect(viewState.lockedRepos.issues).toEqual(["org/repo-a"]); + }); + }); + + describe("pruneLockedRepos", () => { + it("removes stale names", () => { + lockRepo("pullRequests", "org/repo-a"); + lockRepo("pullRequests", "org/repo-b"); + lockRepo("pullRequests", "org/repo-c"); + pruneLockedRepos("pullRequests", ["org/repo-a", "org/repo-c"]); + expect(viewState.lockedRepos.pullRequests).toEqual(["org/repo-a", "org/repo-c"]); + }); + + it("preserves order of active repos", () => { + lockRepo("pullRequests", "org/repo-c"); + lockRepo("pullRequests", "org/repo-a"); + lockRepo("pullRequests", "org/repo-b"); + pruneLockedRepos("pullRequests", ["org/repo-b", "org/repo-c"]); + expect(viewState.lockedRepos.pullRequests).toEqual(["org/repo-c", "org/repo-b"]); + }); + + it("no-op when empty", () => { + pruneLockedRepos("pullRequests", ["org/repo-a"]); + expect(viewState.lockedRepos.pullRequests).toEqual([]); + }); + + it("no-op when all active", () => { + lockRepo("actions", "org/repo-a"); + pruneLockedRepos("actions", ["org/repo-a", "org/repo-b"]); + expect(viewState.lockedRepos.actions).toEqual(["org/repo-a"]); + }); + }); + + describe("schema migration", () => { + it("defaults lockedRepos when absent", () => { + const result = ViewStateSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.lockedRepos).toEqual({ + issues: [], + pullRequests: [], + actions: [], + }); + } + }); + + it("preserves existing data without lockedRepos", () => { + const result = ViewStateSchema.safeParse({ + lastActiveTab: "issues", + expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.lastActiveTab).toBe("issues"); + expect(result.data.lockedRepos).toEqual({ + issues: [], + pullRequests: [], + actions: [], + }); + } + }); + }); +});