Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0c355a5
feat(format): adds shortRelativeTime compact date formatter
wgordon17 Mar 31, 2026
1604ec6
feat(ui): adds updated date display to issue and PR rows
wgordon17 Mar 31, 2026
4250fc6
test(ui): adds dual-date display tests for ItemRow
wgordon17 Mar 31, 2026
5f0901d
fix(ui): addresses review findings for date display
wgordon17 Mar 31, 2026
4176cea
test: future timestamp guard and refreshTick updatedDisplay coverage
wgordon17 Mar 31, 2026
cf0877e
refactor(ui): simplifies hasUpdate memo with destructured timeInfo
wgordon17 Mar 31, 2026
fb78368
refactor(ui): consolidates timeInfo memo and adds boundary test
wgordon17 Mar 31, 2026
0862c3f
fix(a11y): uses semantic time elements for date display
wgordon17 Mar 31, 2026
5cd523d
fix: addresses PR review findings for date display
wgordon17 Mar 31, 2026
7a288a2
fix: reorders hasUpdate guard and adds WorkflowRunRow time tests
wgordon17 Mar 31, 2026
f77841c
fix: addresses PR review findings for date display
wgordon17 Mar 31, 2026
cd4d6d9
test: adds clock tick interval and cleanup assertions
wgordon17 Mar 31, 2026
36f818e
feat(ui): adds repo links, scrollbar, dep filter, flash fix
wgordon17 Mar 31, 2026
a4d0e91
fix(ui): adds flex-wrap to PullRequestsTab toolbar
wgordon17 Mar 31, 2026
8b8fa98
docs: updates Issues Tab description for dep dashboard filter
wgordon17 Mar 31, 2026
bfe745c
fix(ui): moves repo link next to name, fixes sticky focus
wgordon17 Mar 31, 2026
d4ecda3
fix(ui): moves repo link outside button, uses focus-visible
wgordon17 Mar 31, 2026
5592758
refactor(ui): addresses PR review findings across date display PR
wgordon17 Mar 31, 2026
ff9d4cc
test: adds RepoGitHubLink tests and formatDuration NaN guard
wgordon17 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple

## Features

- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated.
- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated. Dependency Dashboard issues hidden by default (toggleable).
- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names.
- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle.
- **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select.
Expand Down
4 changes: 4 additions & 0 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { FilterChipGroupDef } from "../shared/FilterChips";
import ChevronIcon from "../shared/ChevronIcon";
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
import RepoLockControls from "../shared/RepoLockControls";
import RepoGitHubLink from "../shared/RepoGitHubLink";
import { orderRepoGroups } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import { createFlashDetection } from "../../lib/flashDetection";
Expand All @@ -19,6 +20,7 @@ interface ActionsTabProps {
workflowRuns: WorkflowRun[];
loading?: boolean;
hasUpstreamRepos?: boolean;
refreshTick?: number;
hotPollingRunIds?: ReadonlySet<number>;
}

Expand Down Expand Up @@ -318,6 +320,7 @@ export default function ActionsTab(props: ActionsTabProps) {
</span>
</Show>
</button>
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="actions" />
<RepoLockControls tab="actions" repoFullName={repoGroup.repoFullName} />
</div>
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
Expand Down Expand Up @@ -347,6 +350,7 @@ export default function ActionsTab(props: ActionsTabProps) {
onToggle={() => toggleWorkflow(wfKey)}
onIgnoreRun={handleIgnore}
density={config.viewDensity}
refreshTick={props.refreshTick}
hotPollingRunIds={props.hotPollingRunIds}
flashingRunIds={flashingRunIds()}
/>
Expand Down
46 changes: 29 additions & 17 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export default function DashboardPage() {
updateViewState({ lastActiveTab: tab });
}

const [clockTick, setClockTick] = createSignal(0);

onMount(() => {
if (!_coordinator()) {
_setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch));
Expand Down Expand Up @@ -306,15 +308,21 @@ export default function DashboardPage() {
});
}

// Wall-clock tick keeps relative time displays fresh between full poll cycles.
const clockInterval = setInterval(() => setClockTick((t) => t + 1), 60_000);

onCleanup(() => {
_coordinator()?.destroy();
_setCoordinator(null);
_hotCoordinator()?.destroy();
_setHotCoordinator(null);
clearHotSets();
clearInterval(clockInterval);
});
});

const refreshTick = createMemo(() => (dashboardData.lastRefreshedAt?.getTime() ?? 0) + clockTick());

const tabCounts = createMemo(() => ({
issues: dashboardData.issues.length,
pullRequests: dashboardData.pullRequests.length,
Expand All @@ -336,22 +344,23 @@ export default function DashboardPage() {
<Header />

{/* Offset for fixed header */}
<div class="pt-14 flex flex-col h-screen">
{/* Single constrained panel: tabs + filters + content */}
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1 min-h-0 bg-base-100 shadow-lg border-x border-base-300">
<TabBar
activeTab={activeTab()}
onTabChange={handleTabChange}
counts={tabCounts()}
/>

<FilterBar
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
onRefresh={() => _coordinator()?.manualRefresh()}
/>

<main class="flex-1 overflow-auto">
<div class="pt-14 min-h-[calc(100vh-3.5rem)] flex flex-col">
<div class="max-w-6xl mx-auto w-full bg-base-100 shadow-lg border-x border-base-300 flex-1">
<div class="sticky top-14 z-40 bg-base-100">
<TabBar
activeTab={activeTab()}
onTabChange={handleTabChange}
counts={tabCounts()}
/>

<FilterBar
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
onRefresh={() => _coordinator()?.manualRefresh()}
/>
</div>

<main class="pb-12">
<Switch>
<Match when={activeTab() === "issues"}>
<IssuesTab
Expand All @@ -361,6 +370,7 @@ export default function DashboardPage() {
allUsers={allUsers()}
trackedUsers={config.trackedUsers}
monitoredRepos={config.monitoredRepos}
refreshTick={refreshTick()}
/>
</Match>
<Match when={activeTab() === "pullRequests"}>
Expand All @@ -372,21 +382,23 @@ export default function DashboardPage() {
trackedUsers={config.trackedUsers}
hotPollingPRIds={hotPollingPRIds()}
monitoredRepos={config.monitoredRepos}
refreshTick={refreshTick()}
/>
</Match>
<Match when={activeTab() === "actions"}>
<ActionsTab
workflowRuns={dashboardData.workflowRuns}
loading={dashboardData.loading}
hasUpstreamRepos={config.upstreamRepos.length > 0}
refreshTick={refreshTick()}
hotPollingRunIds={hotPollingRunIds()}
/>
</Match>
</Switch>
</main>
</div>

<footer class="border-t border-base-300 bg-base-100 py-3 text-xs text-base-content/50 shrink-0">
<footer class="fixed bottom-0 left-0 right-0 z-30 border-t border-base-300 bg-base-100 py-3 text-xs text-base-content/50">
<div class="max-w-6xl mx-auto w-full px-4 grid grid-cols-3 items-center">
<div />
<div class="flex items-center justify-center gap-3">
Expand Down
22 changes: 20 additions & 2 deletions src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
@@ -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, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
import { viewState, updateViewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
import type { Issue, RepoRef } from "../../services/api";
import ItemRow from "./ItemRow";
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
Expand All @@ -18,6 +18,7 @@ import { deriveInvolvementRoles } from "../../lib/format";
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
import { createReorderHighlight } from "../../lib/reorderHighlight";
import RepoLockControls from "../shared/RepoLockControls";
import RepoGitHubLink from "../shared/RepoGitHubLink";

export interface IssuesTabProps {
issues: Issue[];
Expand All @@ -26,6 +27,7 @@ export interface IssuesTabProps {
allUsers?: { login: string; label: string }[];
trackedUsers?: TrackedUser[];
monitoredRepos?: RepoRef[];
refreshTick?: number;
}

type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments";
Expand Down Expand Up @@ -118,6 +120,8 @@ export default function IssuesTab(props: IssuesTabProps) {
if (tabFilter.comments === "none" && issue.comments > 0) return false;
}

if (viewState.hideDepDashboard && issue.title === "Dependency Dashboard") return false;

if (tabFilter.user !== "all") {
// Items from monitored repos bypass the surfacedBy filter (all activity is shown)
if (!monitoredRepoNameSet().has(issue.repoFullName)) {
Expand Down Expand Up @@ -219,7 +223,7 @@ export default function IssuesTab(props: IssuesTabProps) {
return (
<div class="flex flex-col h-full">
{/* Sort dropdown + filter chips + ignore badge toolbar */}
<div class="flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
<SortDropdown
options={sortOptions}
value={sortPref().field}
Expand All @@ -242,6 +246,17 @@ export default function IssuesTab(props: IssuesTabProps) {
setPage(0);
}}
/>
<button
onClick={() => {
updateViewState({ hideDepDashboard: !viewState.hideDepDashboard });
setPage(0);
}}
class={`btn btn-xs rounded-full ${!viewState.hideDepDashboard ? "btn-primary" : "btn-ghost text-base-content/50"}`}
aria-pressed={!viewState.hideDepDashboard}
title="Toggle visibility of Dependency Dashboard issues"
>
Show Dep Dashboard
</button>
<div class="flex-1" />
<ExpandCollapseButtons
onExpandAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), true)}
Expand Down Expand Up @@ -333,6 +348,7 @@ export default function IssuesTab(props: IssuesTabProps) {
</span>
</Show>
</button>
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="issues" />
<RepoLockControls tab="issues" repoFullName={repoGroup.repoFullName} />
</div>
<Show when={isExpanded()}>
Expand All @@ -347,6 +363,8 @@ export default function IssuesTab(props: IssuesTabProps) {
title={issue.title}
author={issue.userLogin}
createdAt={issue.createdAt}
updatedAt={issue.updatedAt}
refreshTick={props.refreshTick}
url={issue.htmlUrl}
labels={issue.labels}
onIgnore={() => handleIgnore(issue)}
Expand Down
53 changes: 50 additions & 3 deletions src/app/components/dashboard/ItemRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { For, JSX, Show } from "solid-js";
import { createMemo, For, JSX, Show } from "solid-js";
import { isSafeGitHubUrl } from "../../lib/url";
import { relativeTime, labelTextColor, formatCount } from "../../lib/format";
import { relativeTime, shortRelativeTime, labelTextColor, formatCount } from "../../lib/format";
import { expandEmoji } from "../../lib/emoji";

export interface ItemRowProps {
Expand All @@ -9,6 +9,8 @@ export interface ItemRowProps {
title: string;
author: string;
createdAt: string;
updatedAt: string;
refreshTick?: number;
url: string;
labels: { name: string; color: string }[];
children?: JSX.Element;
Expand All @@ -25,6 +27,33 @@ export default function ItemRow(props: ItemRowProps) {
const isCompact = () => props.density === "compact";
const safeUrl = () => isSafeGitHubUrl(props.url) ? props.url : undefined;

// Static date info — recomputed only when createdAt/updatedAt change (not on tick)
const staticDateInfo = createMemo(() => {
const createdTitle = `Created: ${new Date(props.createdAt).toLocaleString()}`;
const updatedTitle = `Updated: ${new Date(props.updatedAt).toLocaleString()}`;
const diffMs = Date.parse(props.updatedAt) - Date.parse(props.createdAt);
return { createdTitle, updatedTitle, diffMs };
});

// Reading props.refreshTick registers it as a SolidJS reactive dependency,
// forcing this memo to re-evaluate when the tick changes. Date.now() alone
// is not tracked by SolidJS's dependency system.
const dateDisplay = createMemo(() => {
void props.refreshTick;
const created = shortRelativeTime(props.createdAt);
const updated = shortRelativeTime(props.updatedAt);
const createdLabel = `Created ${relativeTime(props.createdAt)}`;
const updatedLabel = `Updated ${relativeTime(props.updatedAt)}`;
return { created, updated, createdLabel, updatedLabel };
});

const shouldShowUpdated = createMemo(() => {
const { diffMs } = staticDateInfo();
if (diffMs <= 60_000) return false;
const { created, updated } = dateDisplay();
return created !== "" && updated !== "" && created !== updated;
});

return (
<div
class={`group relative flex items-start gap-3
Expand Down Expand Up @@ -102,7 +131,25 @@ export default function ItemRow(props: ItemRowProps) {
<Show when={props.surfacedByBadge !== undefined}>
<div class="relative z-10">{props.surfacedByBadge}</div>
</Show>
<span title={props.createdAt}>{relativeTime(props.createdAt)}</span>
<span class="inline-flex items-center gap-1 whitespace-nowrap">
<time
datetime={props.createdAt}
title={staticDateInfo().createdTitle}
aria-label={dateDisplay().createdLabel}
>
{dateDisplay().created}
</time>
<Show when={shouldShowUpdated()}>
<span aria-hidden="true">{"\u00B7"}</span>
<time
datetime={props.updatedAt}
title={staticDateInfo().updatedTitle}
aria-label={dateDisplay().updatedLabel}
>
{dateDisplay().updated}
</time>
</Show>
</span>
<Show when={props.isPolling}>
<span class="loading loading-spinner loading-xs text-base-content/40" />
</Show>
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/dashboard/PullRequestsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from
import { createReorderHighlight } from "../../lib/reorderHighlight";
import { createFlashDetection } from "../../lib/flashDetection";
import RepoLockControls from "../shared/RepoLockControls";
import RepoGitHubLink from "../shared/RepoGitHubLink";

export interface PullRequestsTabProps {
pullRequests: PullRequest[];
Expand All @@ -31,6 +32,7 @@ export interface PullRequestsTabProps {
trackedUsers?: TrackedUser[];
hotPollingPRIds?: ReadonlySet<number>;
monitoredRepos?: RepoRef[];
refreshTick?: number;
}

type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size";
Expand Down Expand Up @@ -319,7 +321,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
return (
<div class="flex flex-col h-full">
{/* Filter toolbar with SortDropdown */}
<div class="flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
<SortDropdown
options={sortOptions}
value={sortPref().field}
Expand Down Expand Up @@ -488,6 +490,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
</span>
</Show>
</button>
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="pulls" />
<RepoLockControls tab="pullRequests" repoFullName={repoGroup.repoFullName} />
</div>
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
Expand All @@ -511,6 +514,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
title={pr.title}
author={pr.userLogin}
createdAt={pr.createdAt}
updatedAt={pr.updatedAt}
refreshTick={props.refreshTick}
url={pr.htmlUrl}
labels={pr.labels}
commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined}
Expand Down
23 changes: 19 additions & 4 deletions src/app/components/dashboard/WorkflowRunRow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Show } from "solid-js";
import { createMemo, Show } from "solid-js";
import type { WorkflowRun } from "../../services/api";
import type { Config } from "../../stores/config";
import { isSafeGitHubUrl } from "../../lib/url";
Expand All @@ -8,6 +8,7 @@ interface WorkflowRunRowProps {
run: WorkflowRun;
onIgnore: (run: WorkflowRun) => void;
density: Config["viewDensity"];
refreshTick?: number;
isPolling?: boolean;
isFlashing?: boolean;
}
Expand Down Expand Up @@ -131,6 +132,16 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) {
const paddingClass = () =>
props.density === "compact" ? "py-1.5 px-3" : "py-2.5 px-4";

const createdTitle = createMemo(() => `Created: ${new Date(props.run.createdAt).toLocaleString()}`);

// Reading props.refreshTick registers it as a SolidJS reactive dependency,
// forcing this memo to re-evaluate when the tick changes. Date.now() alone
// is not tracked by SolidJS's dependency system.
const timeLabel = createMemo(() => {
void props.refreshTick;
return relativeTime(props.run.createdAt);
});

return (
<div
class={`flex items-center gap-3 ${paddingClass()} hover:bg-base-200 group ${props.isFlashing ? "animate-flash" : props.isPolling ? "animate-shimmer" : ""}`}
Expand Down Expand Up @@ -171,9 +182,13 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) {
{durationLabel(props.run)}
</span>

<span class="text-xs text-base-content/40 shrink-0">
{relativeTime(props.run.createdAt)}
</span>
<time
class="text-xs text-base-content/40 shrink-0"
datetime={props.run.createdAt}
title={createdTitle()}
>
{timeLabel()}
</time>

<Show when={props.isPolling}>
<span class="loading loading-spinner loading-xs text-base-content/40" />
Expand Down
Loading
Loading