Skip to content

Commit fbc6b74

Browse files
authored
feat(ui): adds created and updated date display to issue and PR rows (#39)
* feat(format): adds shortRelativeTime compact date formatter * feat(ui): adds updated date display to issue and PR rows * test(ui): adds dual-date display tests for ItemRow * fix(ui): addresses review findings for date display * test: future timestamp guard and refreshTick updatedDisplay coverage * refactor(ui): simplifies hasUpdate memo with destructured timeInfo * refactor(ui): consolidates timeInfo memo and adds boundary test * fix(a11y): uses semantic time elements for date display * fix: addresses PR review findings for date display - splits timeInfo into staticDateInfo and dateDisplay memos (perf, clarity) - adds future-timestamp guard to relativeTime (clock skew consistency) - aligns Date.parse usage across relativeTime and shortRelativeTime - documents void props.refreshTick reactive dependency pattern - migrates WorkflowRunRow to semantic <time> element with createMemo - wires refreshTick through ActionsTab to WorkflowRunRow - adds boundary tests (59s/60s, 29d/30d, 11mo/12mo) - adds datetime attribute, zero-diff, and future relativeTime tests * fix: reorders hasUpdate guard and adds WorkflowRunRow time tests * fix: addresses PR review findings for date display - adds 60s wall-clock tick for relative time freshness (cor-1) - memoizes WorkflowRunRow tooltip with Created prefix (cq-1, cq-2) - renames hasUpdate to shouldShowUpdated (cq-5) - simplifies duplicate reactivity comments (cq-4) - switches ItemRow test locators to querySelector (gh-5) - fixes double-mock pattern with vi.mocked (qa-4) - adds 2mo, invalid date, and title attribute tests (qa-1, qa-2, qa-3) * test: adds clock tick interval and cleanup assertions * feat(ui): adds repo links, scrollbar, dep filter, flash fix - adds external-link icon on repo group headers linking to GitHub per tab - moves scrollbar to viewport edge via page scroll with sticky tabs - adds depDashboard filter with toggle pill, hidden by default - fixes detectReorderedRepos false flash on repo add/remove - extracts ExternalLinkIcon shared component - preserves depDashboard toggle across resetAllTabFilters - uses schema defaults in resetTabFilter instead of hardcoded all * fix(ui): adds flex-wrap to PullRequestsTab toolbar * docs: updates Issues Tab description for dep dashboard filter * fix(ui): moves repo link next to name, fixes sticky focus * fix(ui): moves repo link outside button, uses focus-visible * refactor(ui): addresses PR review findings across date display PR - Extracts RepoGitHubLink shared component, replacing duplicated anchor markup in IssuesTab, PullRequestsTab, and ActionsTab - Moves clockInterval inside onMount to match coordinator lifecycle pattern - Promotes depDashboard from string enum in tabFilters to top-level boolean (hideDepDashboard), eliminating resetAllTabFilters preserve hack - Expands void refreshTick comments to explain SolidJS dependency tracking mechanism - Normalizes Date.parse() usage in formatDuration for consistency with relativeTime/shortRelativeTime - Adds tests for mixed valid/invalid dates, 360-day boundary, external link rendering, clock cleanup specificity, and hideDepDashboard lifecycle * test: adds RepoGitHubLink tests and formatDuration NaN guard
1 parent 414af1c commit fbc6b74

21 files changed

+670
-57
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
44

55
## Features
66

7-
- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated.
7+
- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated. Dependency Dashboard issues hidden by default (toggleable).
88
- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names.
99
- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle.
1010
- **Onboarding Wizard** — Two-step org/repo selection with search filtering and bulk select.

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FilterChipGroupDef } from "../shared/FilterChips";
1111
import ChevronIcon from "../shared/ChevronIcon";
1212
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1313
import RepoLockControls from "../shared/RepoLockControls";
14+
import RepoGitHubLink from "../shared/RepoGitHubLink";
1415
import { orderRepoGroups } from "../../lib/grouping";
1516
import { createReorderHighlight } from "../../lib/reorderHighlight";
1617
import { createFlashDetection } from "../../lib/flashDetection";
@@ -19,6 +20,7 @@ interface ActionsTabProps {
1920
workflowRuns: WorkflowRun[];
2021
loading?: boolean;
2122
hasUpstreamRepos?: boolean;
23+
refreshTick?: number;
2224
hotPollingRunIds?: ReadonlySet<number>;
2325
}
2426

@@ -318,6 +320,7 @@ export default function ActionsTab(props: ActionsTabProps) {
318320
</span>
319321
</Show>
320322
</button>
323+
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="actions" />
321324
<RepoLockControls tab="actions" repoFullName={repoGroup.repoFullName} />
322325
</div>
323326
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
@@ -347,6 +350,7 @@ export default function ActionsTab(props: ActionsTabProps) {
347350
onToggle={() => toggleWorkflow(wfKey)}
348351
onIgnoreRun={handleIgnore}
349352
density={config.viewDensity}
353+
refreshTick={props.refreshTick}
350354
hotPollingRunIds={props.hotPollingRunIds}
351355
flashingRunIds={flashingRunIds()}
352356
/>

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ export default function DashboardPage() {
240240
updateViewState({ lastActiveTab: tab });
241241
}
242242

243+
const [clockTick, setClockTick] = createSignal(0);
244+
243245
onMount(() => {
244246
if (!_coordinator()) {
245247
_setCoordinator(createPollCoordinator(() => config.refreshInterval, pollFetch));
@@ -306,15 +308,21 @@ export default function DashboardPage() {
306308
});
307309
}
308310

311+
// Wall-clock tick keeps relative time displays fresh between full poll cycles.
312+
const clockInterval = setInterval(() => setClockTick((t) => t + 1), 60_000);
313+
309314
onCleanup(() => {
310315
_coordinator()?.destroy();
311316
_setCoordinator(null);
312317
_hotCoordinator()?.destroy();
313318
_setHotCoordinator(null);
314319
clearHotSets();
320+
clearInterval(clockInterval);
315321
});
316322
});
317323

324+
const refreshTick = createMemo(() => (dashboardData.lastRefreshedAt?.getTime() ?? 0) + clockTick());
325+
318326
const tabCounts = createMemo(() => ({
319327
issues: dashboardData.issues.length,
320328
pullRequests: dashboardData.pullRequests.length,
@@ -336,22 +344,23 @@ export default function DashboardPage() {
336344
<Header />
337345

338346
{/* Offset for fixed header */}
339-
<div class="pt-14 flex flex-col h-screen">
340-
{/* Single constrained panel: tabs + filters + content */}
341-
<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">
342-
<TabBar
343-
activeTab={activeTab()}
344-
onTabChange={handleTabChange}
345-
counts={tabCounts()}
346-
/>
347-
348-
<FilterBar
349-
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
350-
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
351-
onRefresh={() => _coordinator()?.manualRefresh()}
352-
/>
353-
354-
<main class="flex-1 overflow-auto">
347+
<div class="pt-14 min-h-[calc(100vh-3.5rem)] flex flex-col">
348+
<div class="max-w-6xl mx-auto w-full bg-base-100 shadow-lg border-x border-base-300 flex-1">
349+
<div class="sticky top-14 z-40 bg-base-100">
350+
<TabBar
351+
activeTab={activeTab()}
352+
onTabChange={handleTabChange}
353+
counts={tabCounts()}
354+
/>
355+
356+
<FilterBar
357+
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
358+
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
359+
onRefresh={() => _coordinator()?.manualRefresh()}
360+
/>
361+
</div>
362+
363+
<main class="pb-12">
355364
<Switch>
356365
<Match when={activeTab() === "issues"}>
357366
<IssuesTab
@@ -361,6 +370,7 @@ export default function DashboardPage() {
361370
allUsers={allUsers()}
362371
trackedUsers={config.trackedUsers}
363372
monitoredRepos={config.monitoredRepos}
373+
refreshTick={refreshTick()}
364374
/>
365375
</Match>
366376
<Match when={activeTab() === "pullRequests"}>
@@ -372,21 +382,23 @@ export default function DashboardPage() {
372382
trackedUsers={config.trackedUsers}
373383
hotPollingPRIds={hotPollingPRIds()}
374384
monitoredRepos={config.monitoredRepos}
385+
refreshTick={refreshTick()}
375386
/>
376387
</Match>
377388
<Match when={activeTab() === "actions"}>
378389
<ActionsTab
379390
workflowRuns={dashboardData.workflowRuns}
380391
loading={dashboardData.loading}
381392
hasUpstreamRepos={config.upstreamRepos.length > 0}
393+
refreshTick={refreshTick()}
382394
hotPollingRunIds={hotPollingRunIds()}
383395
/>
384396
</Match>
385397
</Switch>
386398
</main>
387399
</div>
388400

389-
<footer class="border-t border-base-300 bg-base-100 py-3 text-xs text-base-content/50 shrink-0">
401+
<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">
390402
<div class="max-w-6xl mx-auto w-full px-4 grid grid-cols-3 items-center">
391403
<div />
392404
<div class="flex items-center justify-center gap-3">

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
3-
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
3+
import { viewState, updateViewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
44
import type { Issue, RepoRef } from "../../services/api";
55
import ItemRow from "./ItemRow";
66
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
@@ -18,6 +18,7 @@ import { deriveInvolvementRoles } from "../../lib/format";
1818
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
1919
import { createReorderHighlight } from "../../lib/reorderHighlight";
2020
import RepoLockControls from "../shared/RepoLockControls";
21+
import RepoGitHubLink from "../shared/RepoGitHubLink";
2122

2223
export interface IssuesTabProps {
2324
issues: Issue[];
@@ -26,6 +27,7 @@ export interface IssuesTabProps {
2627
allUsers?: { login: string; label: string }[];
2728
trackedUsers?: TrackedUser[];
2829
monitoredRepos?: RepoRef[];
30+
refreshTick?: number;
2931
}
3032

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

123+
if (viewState.hideDepDashboard && issue.title === "Dependency Dashboard") return false;
124+
121125
if (tabFilter.user !== "all") {
122126
// Items from monitored repos bypass the surfacedBy filter (all activity is shown)
123127
if (!monitoredRepoNameSet().has(issue.repoFullName)) {
@@ -219,7 +223,7 @@ export default function IssuesTab(props: IssuesTabProps) {
219223
return (
220224
<div class="flex flex-col h-full">
221225
{/* Sort dropdown + filter chips + ignore badge toolbar */}
222-
<div class="flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
226+
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
223227
<SortDropdown
224228
options={sortOptions}
225229
value={sortPref().field}
@@ -242,6 +246,17 @@ export default function IssuesTab(props: IssuesTabProps) {
242246
setPage(0);
243247
}}
244248
/>
249+
<button
250+
onClick={() => {
251+
updateViewState({ hideDepDashboard: !viewState.hideDepDashboard });
252+
setPage(0);
253+
}}
254+
class={`btn btn-xs rounded-full ${!viewState.hideDepDashboard ? "btn-primary" : "btn-ghost text-base-content/50"}`}
255+
aria-pressed={!viewState.hideDepDashboard}
256+
title="Toggle visibility of Dependency Dashboard issues"
257+
>
258+
Show Dep Dashboard
259+
</button>
245260
<div class="flex-1" />
246261
<ExpandCollapseButtons
247262
onExpandAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), true)}
@@ -333,6 +348,7 @@ export default function IssuesTab(props: IssuesTabProps) {
333348
</span>
334349
</Show>
335350
</button>
351+
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="issues" />
336352
<RepoLockControls tab="issues" repoFullName={repoGroup.repoFullName} />
337353
</div>
338354
<Show when={isExpanded()}>
@@ -347,6 +363,8 @@ export default function IssuesTab(props: IssuesTabProps) {
347363
title={issue.title}
348364
author={issue.userLogin}
349365
createdAt={issue.createdAt}
366+
updatedAt={issue.updatedAt}
367+
refreshTick={props.refreshTick}
350368
url={issue.htmlUrl}
351369
labels={issue.labels}
352370
onIgnore={() => handleIgnore(issue)}

src/app/components/dashboard/ItemRow.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { For, JSX, Show } from "solid-js";
1+
import { createMemo, For, JSX, Show } from "solid-js";
22
import { isSafeGitHubUrl } from "../../lib/url";
3-
import { relativeTime, labelTextColor, formatCount } from "../../lib/format";
3+
import { relativeTime, shortRelativeTime, labelTextColor, formatCount } from "../../lib/format";
44
import { expandEmoji } from "../../lib/emoji";
55

66
export interface ItemRowProps {
@@ -9,6 +9,8 @@ export interface ItemRowProps {
99
title: string;
1010
author: string;
1111
createdAt: string;
12+
updatedAt: string;
13+
refreshTick?: number;
1214
url: string;
1315
labels: { name: string; color: string }[];
1416
children?: JSX.Element;
@@ -25,6 +27,33 @@ export default function ItemRow(props: ItemRowProps) {
2527
const isCompact = () => props.density === "compact";
2628
const safeUrl = () => isSafeGitHubUrl(props.url) ? props.url : undefined;
2729

30+
// Static date info — recomputed only when createdAt/updatedAt change (not on tick)
31+
const staticDateInfo = createMemo(() => {
32+
const createdTitle = `Created: ${new Date(props.createdAt).toLocaleString()}`;
33+
const updatedTitle = `Updated: ${new Date(props.updatedAt).toLocaleString()}`;
34+
const diffMs = Date.parse(props.updatedAt) - Date.parse(props.createdAt);
35+
return { createdTitle, updatedTitle, diffMs };
36+
});
37+
38+
// Reading props.refreshTick registers it as a SolidJS reactive dependency,
39+
// forcing this memo to re-evaluate when the tick changes. Date.now() alone
40+
// is not tracked by SolidJS's dependency system.
41+
const dateDisplay = createMemo(() => {
42+
void props.refreshTick;
43+
const created = shortRelativeTime(props.createdAt);
44+
const updated = shortRelativeTime(props.updatedAt);
45+
const createdLabel = `Created ${relativeTime(props.createdAt)}`;
46+
const updatedLabel = `Updated ${relativeTime(props.updatedAt)}`;
47+
return { created, updated, createdLabel, updatedLabel };
48+
});
49+
50+
const shouldShowUpdated = createMemo(() => {
51+
const { diffMs } = staticDateInfo();
52+
if (diffMs <= 60_000) return false;
53+
const { created, updated } = dateDisplay();
54+
return created !== "" && updated !== "" && created !== updated;
55+
});
56+
2857
return (
2958
<div
3059
class={`group relative flex items-start gap-3
@@ -102,7 +131,25 @@ export default function ItemRow(props: ItemRowProps) {
102131
<Show when={props.surfacedByBadge !== undefined}>
103132
<div class="relative z-10">{props.surfacedByBadge}</div>
104133
</Show>
105-
<span title={props.createdAt}>{relativeTime(props.createdAt)}</span>
134+
<span class="inline-flex items-center gap-1 whitespace-nowrap">
135+
<time
136+
datetime={props.createdAt}
137+
title={staticDateInfo().createdTitle}
138+
aria-label={dateDisplay().createdLabel}
139+
>
140+
{dateDisplay().created}
141+
</time>
142+
<Show when={shouldShowUpdated()}>
143+
<span aria-hidden="true">{"\u00B7"}</span>
144+
<time
145+
datetime={props.updatedAt}
146+
title={staticDateInfo().updatedTitle}
147+
aria-label={dateDisplay().updatedLabel}
148+
>
149+
{dateDisplay().updated}
150+
</time>
151+
</Show>
152+
</span>
106153
<Show when={props.isPolling}>
107154
<span class="loading loading-spinner loading-xs text-base-content/40" />
108155
</Show>

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from
2222
import { createReorderHighlight } from "../../lib/reorderHighlight";
2323
import { createFlashDetection } from "../../lib/flashDetection";
2424
import RepoLockControls from "../shared/RepoLockControls";
25+
import RepoGitHubLink from "../shared/RepoGitHubLink";
2526

2627
export interface PullRequestsTabProps {
2728
pullRequests: PullRequest[];
@@ -31,6 +32,7 @@ export interface PullRequestsTabProps {
3132
trackedUsers?: TrackedUser[];
3233
hotPollingPRIds?: ReadonlySet<number>;
3334
monitoredRepos?: RepoRef[];
35+
refreshTick?: number;
3436
}
3537

3638
type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size";
@@ -319,7 +321,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
319321
return (
320322
<div class="flex flex-col h-full">
321323
{/* Filter toolbar with SortDropdown */}
322-
<div class="flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
324+
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
323325
<SortDropdown
324326
options={sortOptions}
325327
value={sortPref().field}
@@ -488,6 +490,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
488490
</span>
489491
</Show>
490492
</button>
493+
<RepoGitHubLink repoFullName={repoGroup.repoFullName} section="pulls" />
491494
<RepoLockControls tab="pullRequests" repoFullName={repoGroup.repoFullName} />
492495
</div>
493496
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
@@ -511,6 +514,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
511514
title={pr.title}
512515
author={pr.userLogin}
513516
createdAt={pr.createdAt}
517+
updatedAt={pr.updatedAt}
518+
refreshTick={props.refreshTick}
514519
url={pr.htmlUrl}
515520
labels={pr.labels}
516521
commentCount={pr.enriched !== false ? pr.comments + pr.reviewThreads : undefined}

src/app/components/dashboard/WorkflowRunRow.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Show } from "solid-js";
1+
import { createMemo, Show } from "solid-js";
22
import type { WorkflowRun } from "../../services/api";
33
import type { Config } from "../../stores/config";
44
import { isSafeGitHubUrl } from "../../lib/url";
@@ -8,6 +8,7 @@ interface WorkflowRunRowProps {
88
run: WorkflowRun;
99
onIgnore: (run: WorkflowRun) => void;
1010
density: Config["viewDensity"];
11+
refreshTick?: number;
1112
isPolling?: boolean;
1213
isFlashing?: boolean;
1314
}
@@ -131,6 +132,16 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) {
131132
const paddingClass = () =>
132133
props.density === "compact" ? "py-1.5 px-3" : "py-2.5 px-4";
133134

135+
const createdTitle = createMemo(() => `Created: ${new Date(props.run.createdAt).toLocaleString()}`);
136+
137+
// Reading props.refreshTick registers it as a SolidJS reactive dependency,
138+
// forcing this memo to re-evaluate when the tick changes. Date.now() alone
139+
// is not tracked by SolidJS's dependency system.
140+
const timeLabel = createMemo(() => {
141+
void props.refreshTick;
142+
return relativeTime(props.run.createdAt);
143+
});
144+
134145
return (
135146
<div
136147
class={`flex items-center gap-3 ${paddingClass()} hover:bg-base-200 group ${props.isFlashing ? "animate-flash" : props.isPolling ? "animate-shimmer" : ""}`}
@@ -171,9 +182,13 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) {
171182
{durationLabel(props.run)}
172183
</span>
173184

174-
<span class="text-xs text-base-content/40 shrink-0">
175-
{relativeTime(props.run.createdAt)}
176-
</span>
185+
<time
186+
class="text-xs text-base-content/40 shrink-0"
187+
datetime={props.run.createdAt}
188+
title={createdTitle()}
189+
>
190+
{timeLabel()}
191+
</time>
177192

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

0 commit comments

Comments
 (0)