diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index fa3c6b9..d0f1c92 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -87,18 +87,18 @@ test("back link navigates to dashboard", async ({ page }) => { // ── Theme change ───────────────────────────────────────────────────────────── -test("changing theme to dark adds dark class to html element", async ({ +test("changing theme to dark applies data-theme attribute", async ({ page, }) => { await setupAuth(page); await page.goto("/settings"); - // Locate the Theme setting row by its label text, then find its setViewState("showPrRuns", e.currentTarget.checked)} - class="rounded border-gray-300 dark:border-gray-600 text-blue-500 focus:ring-blue-500" + class="checkbox checkbox-sm checkbox-primary" /> Show PR runs @@ -203,7 +215,7 @@ export default function ActionsTab(props: ActionsTabProps) { !props.loading && repoGroups().length === 0 } > -
+

No workflow runs found.

@@ -212,59 +224,85 @@ export default function ActionsTab(props: ActionsTabProps) { 0}> {(repoGroup) => { - const isRepoCollapsed = () => - collapsedRepos[repoGroup.repoFullName]; + const isExpanded = () => !!expandedRepos[repoGroup.repoFullName]; + + const sortedWorkflows = createMemo(() => + sortWorkflowsByStatus(repoGroup.workflows) + ); + + const collapsedSummary = createMemo(() => { + const wfs = repoGroup.workflows; + const total = wfs.length; + let passed = 0; + let failed = 0; + let running = 0; + for (const wf of wfs) { + const latest = wf.runs[0]; + if (latest?.conclusion === "success") passed++; + else if (latest?.conclusion === "failure") failed++; + else if (latest?.status === "in_progress") running++; + } + return { total, passed, failed, running }; + }); return ( -
+
{/* Repo header */} - {/* Workflow groups */} - - - {(wfGroup) => { - const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; - const isWfCollapsed = () => - collapsedWorkflows[wfKey]; - - return ( -
- {/* Workflow header */} - - - {/* Runs */} - -
- - {(run) => ( - - )} - -
-
-
- ); - }} -
+ {/* Workflow cards grid */} + +
+ + {(wfGroup) => { + const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; + const isWfExpanded = () => !!expandedWorkflows[wfKey]; + + return ( +
+ toggleWorkflow(wfKey)} + onIgnoreRun={handleIgnore} + density={config.viewDensity} + /> +
+ ); + }} +
+
); diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 74e9522..addd234 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { createSignal, createMemo, Switch, Match, onMount, onCleanup } from "solid-js"; +import { createSignal, createMemo, Show, Switch, Match, onMount, onCleanup } from "solid-js"; import { createStore } from "solid-js/store"; import Header from "../layout/Header"; import TabBar, { TabId } from "../layout/TabBar"; @@ -6,11 +6,13 @@ import FilterBar from "../layout/FilterBar"; import ActionsTab from "./ActionsTab"; import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; -import { config } from "../../stores/config"; +import { config, setConfig } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; +import { fetchOrgs } from "../../services/api"; import { createPollCoordinator, fetchAllData, type DashboardData } from "../../services/poll"; import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth"; +import { getClient, getGraphqlRateLimit } from "../../services/github"; // ── Shared dashboard store (module-level to survive navigation) ───────────── @@ -153,6 +155,26 @@ export default function DashboardPage() { if (!_coordinator()) { _setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch)); } + + // Auto-sync orgs on dashboard load — picks up newly accessible orgs + // after re-auth, scope changes, or org policy updates. + // Only adds orgs to the filter list — repos are user-selected via Settings. + const client = getClient(); + if (client && config.onboardingComplete) { + void fetchOrgs(client).then((allOrgs) => { + const currentSet = new Set(config.selectedOrgs.map((o) => o.toLowerCase())); + const newOrgs = allOrgs + .map((o) => o.login) + .filter((login) => !currentSet.has(login.toLowerCase())); + if (newOrgs.length > 0) { + setConfig("selectedOrgs", [...config.selectedOrgs, ...newOrgs]); + console.info(`[dashboard] auto-synced ${newOrgs.length} new org(s)`); + } + }).catch(() => { + // Non-fatal — org sync failure doesn't block dashboard + }); + } + onCleanup(() => { _coordinator()?.destroy(); _setCoordinator(null); @@ -168,64 +190,83 @@ export default function DashboardPage() { const userLogin = createMemo(() => user()?.login ?? ""); return ( -
+
{/* Offset for fixed header */}
- - - _coordinator()?.manualRefresh()} - /> - -
- - - - - - - - - - - -
- -
- - Source - - - - Privacy - + {/* Single constrained panel: tabs + filters + content */} +
+ + + _coordinator()?.manualRefresh()} + /> + +
+ + + + + + + + + + + +
+
+ +
+
+
+ +
+ + {(rl) => ( +
+ + API RL: {rl().remaining.toLocaleString()}/5k/hr + +
+ )} +
+
+
diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index 181d6c6..cebddd6 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -45,10 +45,7 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {
@@ -106,10 +102,10 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) { -
+
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index bcfa1d0..11a16c7 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -5,7 +5,8 @@ import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTab import type { Issue } from "../../services/api"; import ItemRow from "./ItemRow"; import IgnoreBadge from "./IgnoreBadge"; -import SortIcon from "../shared/SortIcon"; +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"; @@ -42,12 +43,21 @@ const issueFilterGroups: FilterChipGroupDef[] = [ }, ]; +const sortOptions: SortOption[] = [ + { label: "Repo", field: "repo", type: "text" }, + { label: "Title", field: "title", type: "text" }, + { label: "Author", field: "author", type: "text" }, + { label: "Comments", field: "comments", type: "number" }, + { label: "Created", field: "createdAt", type: "date" }, + { label: "Updated", field: "updatedAt", type: "date" }, +]; + export default function IssuesTab(props: IssuesTabProps) { const [page, setPage] = createSignal(0); - const [collapsedRepos, setCollapsedRepos] = createStore>({}); + const [expandedRepos, setExpandedRepos] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos(repoFullName, (v) => !v); + setExpandedRepos(repoFullName, (v) => !v); } const sortPref = createMemo(() => { @@ -131,10 +141,17 @@ export default function IssuesTab(props: IssuesTabProps) { if (page() > max) setPage(max); }); - function handleSort(field: SortField) { - const current = sortPref(); - const direction = - current.field === field && current.direction === "desc" ? "asc" : "desc"; + // Auto-expand first group on initial mount + let hasAutoExpanded = false; + createEffect(() => { + const groups = pageGroups(); + if (!hasAutoExpanded && groups.length > 0) { + hasAutoExpanded = true; + setExpandedRepos(groups[0].repoFullName, true); + } + }); + + function handleSort(field: string, direction: "asc" | "desc") { setSortPreference("issues", field, direction); setPage(0); } @@ -149,47 +166,16 @@ export default function IssuesTab(props: IssuesTabProps) { }); } - const columnHeaders: { label: string; field: SortField }[] = [ - { label: "Repo", field: "repo" }, - { label: "Title", field: "title" }, - { label: "Author", field: "author" }, - { label: "Comments", field: "comments" }, - { label: "Created", field: "createdAt" }, - { label: "Updated", field: "updatedAt" }, - ]; - return (
- {/* Column headers */} -
- - {(col) => ( - - )} - - -
- i.type === "issue")} - onUnignore={unignoreItem} + {/* Sort dropdown + filter chips + ignore badge toolbar */} +
+ -
- - {/* Filter chips */} -
+
+ i.type === "issue")} + onUnignore={unignoreItem} + />
{/* Loading skeleton — only when no data exists yet */} @@ -218,7 +209,7 @@ export default function IssuesTab(props: IssuesTabProps) { 0} fallback={ -
+
} > -
+
{(repoGroup) => { - const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName]; + const isExpanded = () => !!expandedRepos[repoGroup.repoFullName]; + + const roleSummary = createMemo(() => { + const counts: Record = {}; + for (const item of repoGroup.items) { + const m = issueMeta().get(item.id); + if (m) { + for (const role of m.roles) { + counts[role] = (counts[role] || 0) + 1; + } + } + } + return Object.entries(counts); + }); + return ( -
+
- -
+ +
{(issue) => (
diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index fd19b83..ca72701 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -38,16 +38,15 @@ export default function ItemRow(props: ItemRowProps) { } }} class={`group relative flex items-start gap-3 cursor-pointer - border-b border-gray-200 dark:border-gray-700 - hover:bg-gray-50 dark:hover:bg-gray-800/60 - transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-800/60 + hover:bg-base-200 + transition-colors focus:outline-none focus:bg-base-200 focus-visible:ring-2 focus-visible:ring-primary ${isCompact() ? "px-4 py-2" : "px-4 py-3"}`} > {/* Repo badge */} @@ -58,10 +57,10 @@ export default function ItemRow(props: ItemRowProps) { {/* Main content */}
- + #{props.number} - + {props.title}
@@ -94,7 +93,7 @@ export default function ItemRow(props: ItemRowProps) {
{/* Author + time + comment count */} -
+
{props.author} {relativeTime(props.createdAt)} 0}> @@ -125,10 +124,10 @@ export default function ItemRow(props: ItemRowProps) { props.onIgnore(); }} class={`shrink-0 self-center rounded p-1 - text-gray-300 dark:text-gray-600 - hover:text-red-500 dark:hover:text-red-400 + text-base-content/30 + hover:text-error opacity-0 group-hover:opacity-100 focus:opacity-100 - transition-opacity focus:outline-none focus:ring-2 focus:ring-red-400`} + transition-opacity focus:outline-none focus:ring-2 focus:ring-error`} title="Ignore this item" aria-label={`Ignore #${props.number} ${props.title}`} > diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index c67cf01..f06e5fb 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -7,7 +7,8 @@ import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; import ItemRow from "./ItemRow"; import StatusDot from "../shared/StatusDot"; import IgnoreBadge from "./IgnoreBadge"; -import SortIcon from "../shared/SortIcon"; +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"; @@ -30,12 +31,14 @@ function checkStatusOrder(status: PullRequest["checkStatus"]): number { switch (status) { case "failure": return 0; - case "pending": + case "conflict": return 1; - case "success": + case "pending": return 2; - default: + case "success": return 3; + default: + return 4; } } @@ -86,6 +89,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ { value: "success", label: "Passing" }, { value: "failure", label: "Failing" }, { value: "pending", label: "Pending" }, + { value: "conflict", label: "Conflict" }, { value: "none", label: "No CI" }, ], }, @@ -102,12 +106,23 @@ const prFilterGroups: FilterChipGroupDef[] = [ }, ]; +const sortOptions: SortOption[] = [ + { label: "Repo", field: "repo", type: "text" }, + { label: "Title", field: "title", type: "text" }, + { label: "Author", field: "author", type: "text" }, + { label: "Checks", field: "checkStatus", type: "text" }, + { label: "Review", field: "reviewDecision", type: "text" }, + { label: "Size", field: "size", type: "number" }, + { label: "Created", field: "createdAt", type: "date" }, + { label: "Updated", field: "updatedAt", type: "date" }, +]; + export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); - const [collapsedRepos, setCollapsedRepos] = createStore>({}); + const [expandedRepos, setExpandedRepos] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos(repoFullName, (v) => !v); + setExpandedRepos(repoFullName, (v) => !v); } const sortPref = createMemo(() => { @@ -211,10 +226,17 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (page() > max) setPage(max); }); - function handleSort(field: SortField) { - const current = sortPref(); - const direction = - current.field === field && current.direction === "desc" ? "asc" : "desc"; + // Auto-expand first group on initial load + let hasAutoExpanded = false; + createEffect(() => { + const groups = pageGroups(); + if (!hasAutoExpanded && groups.length > 0) { + hasAutoExpanded = true; + setExpandedRepos(groups[0].repoFullName, true); + } + }); + + function handleSort(field: string, direction: "asc" | "desc") { setSortPreference("pullRequests", field, direction); setPage(0); } @@ -229,48 +251,16 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { }); } - const columnHeaders: { label: string; field: SortField }[] = [ - { label: "Repo", field: "repo" }, - { label: "Title", field: "title" }, - { label: "Author", field: "author" }, - { label: "Checks", field: "checkStatus" }, - { label: "Review", field: "reviewDecision" }, - { label: "Size", field: "size" }, - { label: "Created", field: "createdAt" }, - { label: "Updated", field: "updatedAt" }, - ]; - return (
- {/* Column headers */} -
- - {(col) => ( - - )} - -
- i.type === "pullRequest")} - onUnignore={unignoreItem} + {/* Filter toolbar with SortDropdown */} +
+ -
- - {/* Filter chips */} -
+
+ i.type === "pullRequest")} + onUnignore={unignoreItem} + />
{/* Loading skeleton — only when no data exists yet */} @@ -299,7 +294,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { 0} fallback={ -
+
} > -
+
{(repoGroup) => { - const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName]; + const isExpanded = () => !!expandedRepos[repoGroup.repoFullName]; + + const summaryMeta = createMemo(() => { + const checks = { success: 0, failure: 0, pending: 0, conflict: 0 }; + const reviews = { APPROVED: 0, CHANGES_REQUESTED: 0, REVIEW_REQUIRED: 0 }; + const roles: Record = {}; + + for (const item of repoGroup.items) { + if (item.checkStatus === "success") checks.success++; + else if (item.checkStatus === "failure") checks.failure++; + else if (item.checkStatus === "pending") checks.pending++; + else if (item.checkStatus === "conflict") checks.conflict++; + + if (item.reviewDecision === "APPROVED") reviews.APPROVED++; + else if (item.reviewDecision === "CHANGES_REQUESTED") reviews.CHANGES_REQUESTED++; + else if (item.reviewDecision === "REVIEW_REQUIRED") reviews.REVIEW_REQUIRED++; + + const m = prMeta().get(item.id); + if (m) { + for (const role of m.roles) { + roles[role] = (roles[role] || 0) + 1; + } + } + } + + return { checks, reviews, roles: Object.entries(roles) }; + }); + return ( -
+
- -
+ +
{(pr) => (
@@ -356,15 +436,23 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
- - + + + + + + Merge conflict + + - + Draft 0}> - + Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 3d34fcc..781f5ca 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -16,7 +16,7 @@ function StatusIcon(props: { status: string; conclusion: string | null }) { return ( @@ -134,43 +134,43 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { href={isSafeGitHubUrl(props.run.htmlUrl) ? props.run.htmlUrl : undefined} target="_blank" rel="noopener noreferrer" - class="flex-1 min-w-0 flex flex-col gap-0.5 text-sm hover:text-blue-600 dark:hover:text-blue-400" + class="flex-1 min-w-0 flex flex-col gap-0.5 text-sm hover:text-primary" > - + {props.run.displayTitle} - + {props.run.name} - + PR 1}> - + Attempt {props.run.runAttempt} - + {props.run.actorLogin} - + {durationLabel(props.run)} - + {relativeTime(props.run.createdAt)}
diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index 21b8c2e..a3b9567 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -1,7 +1,6 @@ import { createSignal, Show } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { user, clearAuth } from "../../stores/auth"; -import { getCoreRateLimit, getGraphqlRateLimit } from "../../services/github"; import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; @@ -26,51 +25,16 @@ export default function Header() { const unreadCount = () => getUnreadCount(); - const coreRL = () => getCoreRateLimit(); - const graphqlRL = () => getGraphqlRateLimit(); - - function formatLimit(remaining: number, limit: number, unit: string): string { - const k = limit >= 1000 ? `${limit / 1000}k` : String(limit); - return `${remaining}/${k}/${unit}`; - } - return ( <> -
- + setDrawerOpen(false)} /> diff --git a/src/app/components/layout/TabBar.tsx b/src/app/components/layout/TabBar.tsx index d87fc38..4b7f6c9 100644 --- a/src/app/components/layout/TabBar.tsx +++ b/src/app/components/layout/TabBar.tsx @@ -1,4 +1,5 @@ -import { For } from "solid-js"; +import { Tabs } from "@kobalte/core/tabs"; +import { Show } from "solid-js"; export type TabId = "issues" | "pullRequests" | "actions"; @@ -14,54 +15,33 @@ interface TabBarProps { counts?: TabCounts; } -interface Tab { - id: TabId; - label: string; -} - -const TABS: Tab[] = [ - { id: "issues", label: "Issues" }, - { id: "pullRequests", label: "Pull Requests" }, - { id: "actions", label: "Actions" }, -]; - export default function TabBar(props: TabBarProps) { return ( - + props.onTabChange(val as TabId)}> +
+
+ + + Issues + + {props.counts?.issues} + + + + Pull Requests + + {props.counts?.pullRequests} + + + + Actions + + {props.counts?.actions} + + + +
+
+
); } diff --git a/src/app/components/onboarding/OnboardingWizard.tsx b/src/app/components/onboarding/OnboardingWizard.tsx index 96a1022..faf158a 100644 --- a/src/app/components/onboarding/OnboardingWizard.tsx +++ b/src/app/components/onboarding/OnboardingWizard.tsx @@ -61,57 +61,57 @@ export default function OnboardingWizard() { } return ( -
+
{/* Header */}
-

+

GitHub Tracker Setup

-

+

Select the repositories you want to track.

{/* Content */} -
- - -
-

- {error()} -

- -
-
- -
- -
-
- -
-

- Select Repositories -

-

- Choose which repositories to track. -

-
- -
-
+
+
+ + +
+
{error()}
+ +
+
+ +
+ +
+
+ +
+

+ Select Repositories +

+

+ Choose which repositories to track. +

+
+ +
+
+
{/* Navigation buttons — hidden during loading/error to avoid confusion */} @@ -121,7 +121,7 @@ export default function OnboardingWizard() { type="button" onClick={handleFinish} disabled={selectedRepos().length === 0} - class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-600" + class="btn btn-primary" > {selectedRepos().length === 0 ? "Finish Setup" diff --git a/src/app/components/onboarding/OrgSelector.tsx b/src/app/components/onboarding/OrgSelector.tsx index 87130f1..8f43848 100644 --- a/src/app/components/onboarding/OrgSelector.tsx +++ b/src/app/components/onboarding/OrgSelector.tsx @@ -64,7 +64,7 @@ export default function OrgSelector(props: OrgSelectorProps) { type="button" onClick={selectAll} disabled={allVisibleSelected() || filteredOrgs().length === 0} - class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + class="btn btn-ghost btn-xs" > Select All @@ -72,7 +72,7 @@ export default function OrgSelector(props: OrgSelectorProps) { type="button" onClick={deselectAll} disabled={props.selected.length === 0} - class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + class="btn btn-ghost btn-xs" > Deselect All @@ -86,7 +86,7 @@ export default function OrgSelector(props: OrgSelectorProps) { -
+
Failed to load organizations. Please check your connection and try again.
@@ -95,22 +95,22 @@ export default function OrgSelector(props: OrgSelectorProps) { 0} fallback={ -

+

No organizations match your filter.

} > -