From 0c355a5e8a35f226628aaeb663ae19aba2c80bf0 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:05:32 -0400 Subject: [PATCH 01/19] feat(format): adds shortRelativeTime compact date formatter --- src/app/lib/format.ts | 20 +++++++++++++++ tests/lib/format.test.ts | 54 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index df769619..adcbf32c 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -21,6 +21,26 @@ export function relativeTime(isoString: string): string { return rtf.format(-Math.floor(diffMonth / 12), "year"); } +/** + * Formats an ISO date string as a compact relative time string (e.g., "3h", "7d", "2mo"). + * Returns "now" for differences under 60 seconds, "" for invalid input. + */ +export function shortRelativeTime(isoString: string): string { + const diffMs = Date.now() - new Date(isoString).getTime(); + if (isNaN(diffMs)) return ""; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return "now"; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d`; + const diffMonth = Math.floor(diffDay / 30); + if (diffMonth < 12) return `${diffMonth}mo`; + return `${Math.floor(diffMonth / 12)}y`; +} + /** * Computes text color (black or white) for a GitHub label hex color. * Based on perceived luminance. diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts index 4a1c1e1f..b6ccacd1 100644 --- a/tests/lib/format.test.ts +++ b/tests/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { relativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount } from "../../src/app/lib/format"; +import { relativeTime, shortRelativeTime, labelTextColor, formatDuration, prSizeCategory, deriveInvolvementRoles, formatCount } from "../../src/app/lib/format"; describe("relativeTime", () => { beforeEach(() => { @@ -60,6 +60,58 @@ describe("relativeTime", () => { }); }); +describe("shortRelativeTime", () => { + const MOCK_NOW = new Date("2026-03-21T12:00:00.000Z").getTime(); + + beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(MOCK_NOW); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns 'now' for under 60 seconds ago", () => { + const isoString = new Date(MOCK_NOW - 30 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("now"); + }); + + it("returns compact minutes for 5 minutes ago", () => { + const isoString = new Date(MOCK_NOW - 5 * 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("5m"); + }); + + it("returns compact hours for 3 hours ago", () => { + const isoString = new Date(MOCK_NOW - 3 * 60 * 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("3h"); + }); + + it("returns compact days for 7 days ago", () => { + const isoString = new Date(MOCK_NOW - 7 * 24 * 60 * 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("7d"); + }); + + it("returns compact months for 45 days ago", () => { + const isoString = new Date(MOCK_NOW - 45 * 24 * 60 * 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("1mo"); + }); + + it("returns compact years for 400 days ago", () => { + const isoString = new Date(MOCK_NOW - 400 * 24 * 60 * 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("1y"); + }); + + it("returns 'now' for future timestamps (clock skew)", () => { + const isoString = new Date(MOCK_NOW + 60 * 1000).toISOString(); + expect(shortRelativeTime(isoString)).toBe("now"); + }); + + it("returns empty string for invalid input", () => { + expect(shortRelativeTime("not-a-date")).toBe(""); + expect(shortRelativeTime("")).toBe(""); + }); +}); + describe("labelTextColor", () => { it("returns #000000 for white (#ffffff)", () => { expect(labelTextColor("ffffff")).toBe("#000000"); From 1604ec607fefab82dfa1f70fa7513d8115de456d Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:07:14 -0400 Subject: [PATCH 02/19] feat(ui): adds updated date display to issue and PR rows --- .../components/dashboard/DashboardPage.tsx | 4 ++ src/app/components/dashboard/IssuesTab.tsx | 3 ++ src/app/components/dashboard/ItemRow.tsx | 46 +++++++++++++++++-- .../components/dashboard/PullRequestsTab.tsx | 3 ++ tests/components/ItemRow.test.tsx | 16 +++++-- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 393ec83c..3d2aea28 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -315,6 +315,8 @@ export default function DashboardPage() { }); }); + const refreshTick = createMemo(() => dashboardData.lastRefreshedAt?.getTime() ?? 0); + const tabCounts = createMemo(() => ({ issues: dashboardData.issues.length, pullRequests: dashboardData.pullRequests.length, @@ -361,6 +363,7 @@ export default function DashboardPage() { allUsers={allUsers()} trackedUsers={config.trackedUsers} monitoredRepos={config.monitoredRepos} + refreshTick={refreshTick()} /> @@ -372,6 +375,7 @@ export default function DashboardPage() { trackedUsers={config.trackedUsers} hotPollingPRIds={hotPollingPRIds()} monitoredRepos={config.monitoredRepos} + refreshTick={refreshTick()} /> diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 2137958e..34da26a3 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -26,6 +26,7 @@ export interface IssuesTabProps { allUsers?: { login: string; label: string }[]; trackedUsers?: TrackedUser[]; monitoredRepos?: RepoRef[]; + refreshTick?: number; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "comments"; @@ -347,6 +348,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)} diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 9c53bbfa..3067bd18 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -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 { @@ -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; @@ -25,6 +27,28 @@ export default function ItemRow(props: ItemRowProps) { const isCompact = () => props.density === "compact"; const safeUrl = () => isSafeGitHubUrl(props.url) ? props.url : undefined; + const createdDisplay = createMemo(() => { + void props.refreshTick; + return shortRelativeTime(props.createdAt); + }); + const updatedDisplay = createMemo(() => { + void props.refreshTick; + return shortRelativeTime(props.updatedAt); + }); + const createdAriaLabel = createMemo(() => { + void props.refreshTick; + return `Created ${relativeTime(props.createdAt)}`; + }); + const updatedAriaLabel = createMemo(() => { + void props.refreshTick; + return `Updated ${relativeTime(props.updatedAt)}`; + }); + const hasUpdate = createMemo(() => { + const diff = new Date(props.updatedAt).getTime() - new Date(props.createdAt).getTime(); + if (diff <= 60_000) return false; + return createdDisplay() !== updatedDisplay(); + }); + return (
{props.surfacedByBadge}
- {relativeTime(props.createdAt)} + + + {createdDisplay()} + + + + + {updatedDisplay()} + + + diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index a52c484a..e7cd6587 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -31,6 +31,7 @@ export interface PullRequestsTabProps { trackedUsers?: TrackedUser[]; hotPollingPRIds?: ReadonlySet; monitoredRepos?: RepoRef[]; + refreshTick?: number; } type SortField = "repo" | "title" | "author" | "createdAt" | "updatedAt" | "checkStatus" | "reviewDecision" | "size"; @@ -511,6 +512,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} diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 095f7b91..f7b79ac9 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -1,14 +1,17 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import ItemRow from "../../src/app/components/dashboard/ItemRow"; +const MOCK_NOW = new Date("2026-03-30T12:00:00Z").getTime(); + const defaultProps = { repo: "octocat/Hello-World", number: 42, title: "Fix a bug", author: "octocat", - createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2h ago + createdAt: "2026-03-30T10:00:00Z", // 2h before MOCK_NOW + updatedAt: "2026-03-30T11:30:00Z", // 30m before MOCK_NOW (differs from createdAt by >60s) url: "https://github.com/octocat/Hello-World/issues/42", labels: [{ name: "bug", color: "d73a4a" }], onIgnore: vi.fn(), @@ -16,6 +19,9 @@ const defaultProps = { }; describe("ItemRow", () => { + beforeEach(() => { vi.spyOn(Date, "now").mockReturnValue(MOCK_NOW); }); + afterEach(() => { vi.restoreAllMocks(); }); + it("renders repo badge", () => { render(() => ); screen.getByText("octocat/Hello-World"); @@ -39,10 +45,10 @@ describe("ItemRow", () => { it("renders relative time for createdAt", () => { render(() => ); - // Should show something like "2 hours ago" - const timeEl = screen.getByTitle(defaultProps.createdAt); + // Should show compact format like "2h" + const timeEl = screen.getByTitle(`Created: ${defaultProps.createdAt}`); expect(timeEl).toBeDefined(); - expect(timeEl.textContent).toMatch(/hour/i); + expect(timeEl.textContent).toMatch(/^\d+h$/); }); it("renders children slot when provided", () => { From 4250fc6576c98c117b0d0b68a68442a360b33845 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:09:37 -0400 Subject: [PATCH 03/19] test(ui): adds dual-date display tests for ItemRow --- tests/components/ItemRow.test.tsx | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index f7b79ac9..72b96643 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; +import { createSignal } from "solid-js"; import ItemRow from "../../src/app/components/dashboard/ItemRow"; const MOCK_NOW = new Date("2026-03-30T12:00:00Z").getTime(); @@ -172,4 +173,70 @@ describe("ItemRow", () => { expect(container.firstElementChild?.classList.contains("animate-flash")).toBe(true); expect(container.firstElementChild?.classList.contains("animate-shimmer")).toBe(false); }); + + it("shows both dates when updatedAt meaningfully differs from createdAt", () => { + const { container } = render(() => ); + // createdAt=2h ago → "2h", updatedAt=30m ago → "30m" + expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("2h"); + expect(screen.getByTitle(`Updated: ${defaultProps.updatedAt}`).textContent).toBe("30m"); + // Middle dot separator is a with aria-hidden + const dot = container.querySelector('span[aria-hidden="true"]'); + expect(dot).not.toBeNull(); + expect(dot!.textContent).toBe("\u00B7"); + }); + + it("shows single date when updatedAt is within 60s of createdAt", () => { + const { container } = render(() => ( + + )); + // Only one time span — no dot separator span + expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); + expect(screen.queryByTitle(`Updated: 2026-03-30T11:59:30Z`)).toBeNull(); + }); + + it("shows single date when both compact values are identical (display-equality guard)", () => { + // Both 3 days ago — createdAt 3d+2min ago, updatedAt exactly 3d ago, both display "3d" + const createdAt = new Date(MOCK_NOW - (3 * 24 * 60 * 60 + 2 * 60) * 1000).toISOString(); + const updatedAt = new Date(MOCK_NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = render(() => ( + + )); + // diff > 60s but both show "3d" — no dot separator span + expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); + expect(screen.getByTitle(`Created: ${createdAt}`).textContent).toBe("3d"); + }); + + it("shows verbose aria-label for created and updated spans", () => { + render(() => ); + const createdSpan = screen.getByTitle(`Created: ${defaultProps.createdAt}`); + const updatedSpan = screen.getByTitle(`Updated: ${defaultProps.updatedAt}`); + expect(createdSpan.getAttribute("aria-label")).toMatch(/^Created 2 hours? ago$/); + expect(updatedSpan.getAttribute("aria-label")).toMatch(/^Updated 30 minutes? ago$/); + }); + + it("refreshTick forces time display update", () => { + const [tick, setTick] = createSignal(0); + let mockNow = MOCK_NOW; + vi.spyOn(Date, "now").mockImplementation(() => mockNow); + + // createdAt is 2h before MOCK_NOW → displays "2h" + render(() => ( + + )); + expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("2h"); + + // Advance mock time by 3 hours and bump refreshTick + mockNow = MOCK_NOW + 3 * 60 * 60 * 1000; + setTick(1); + + expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("5h"); + }); }); From 5f0901d1c10cdb938376c77138afe9e0c622036b Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:20:38 -0400 Subject: [PATCH 04/19] fix(ui): addresses review findings for date display --- src/app/components/dashboard/ItemRow.tsx | 35 ++++++++++-------------- src/app/lib/format.ts | 3 +- tests/components/ItemRow.test.tsx | 18 ++++++------ 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 3067bd18..89a3af17 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -27,26 +27,19 @@ export default function ItemRow(props: ItemRowProps) { const isCompact = () => props.density === "compact"; const safeUrl = () => isSafeGitHubUrl(props.url) ? props.url : undefined; - const createdDisplay = createMemo(() => { + const timeInfo = createMemo(() => { void props.refreshTick; - return shortRelativeTime(props.createdAt); - }); - const updatedDisplay = createMemo(() => { - void props.refreshTick; - return shortRelativeTime(props.updatedAt); - }); - const createdAriaLabel = createMemo(() => { - void props.refreshTick; - return `Created ${relativeTime(props.createdAt)}`; - }); - const updatedAriaLabel = createMemo(() => { - void props.refreshTick; - return `Updated ${relativeTime(props.updatedAt)}`; + 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 hasUpdate = createMemo(() => { const diff = new Date(props.updatedAt).getTime() - new Date(props.createdAt).getTime(); if (diff <= 60_000) return false; - return createdDisplay() !== updatedDisplay(); + if (timeInfo().created === "" || timeInfo().updated === "") return false; + return timeInfo().created !== timeInfo().updated; }); return ( @@ -128,18 +121,18 @@ export default function ItemRow(props: ItemRowProps) { - {createdDisplay()} + {timeInfo().created} - {updatedDisplay()} + {timeInfo().updated} diff --git a/src/app/lib/format.ts b/src/app/lib/format.ts index adcbf32c..78803643 100644 --- a/src/app/lib/format.ts +++ b/src/app/lib/format.ts @@ -26,8 +26,9 @@ export function relativeTime(isoString: string): string { * Returns "now" for differences under 60 seconds, "" for invalid input. */ export function shortRelativeTime(isoString: string): string { - const diffMs = Date.now() - new Date(isoString).getTime(); + const diffMs = Date.now() - Date.parse(isoString); if (isNaN(diffMs)) return ""; + if (diffMs < 0) return "now"; const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return "now"; const diffMin = Math.floor(diffSec / 60); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 72b96643..91859c27 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -47,7 +47,7 @@ describe("ItemRow", () => { it("renders relative time for createdAt", () => { render(() => ); // Should show compact format like "2h" - const timeEl = screen.getByTitle(`Created: ${defaultProps.createdAt}`); + const timeEl = screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`); expect(timeEl).toBeDefined(); expect(timeEl.textContent).toMatch(/^\d+h$/); }); @@ -177,8 +177,8 @@ describe("ItemRow", () => { it("shows both dates when updatedAt meaningfully differs from createdAt", () => { const { container } = render(() => ); // createdAt=2h ago → "2h", updatedAt=30m ago → "30m" - expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("2h"); - expect(screen.getByTitle(`Updated: ${defaultProps.updatedAt}`).textContent).toBe("30m"); + expect(screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`).textContent).toBe("2h"); + expect(screen.getByTitle(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`).textContent).toBe("30m"); // Middle dot separator is a with aria-hidden const dot = container.querySelector('span[aria-hidden="true"]'); expect(dot).not.toBeNull(); @@ -195,7 +195,7 @@ describe("ItemRow", () => { )); // Only one time span — no dot separator span expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); - expect(screen.queryByTitle(`Updated: 2026-03-30T11:59:30Z`)).toBeNull(); + expect(screen.queryByTitle(`Updated: ${new Date("2026-03-30T11:59:30Z").toLocaleString()}`)).toBeNull(); }); it("shows single date when both compact values are identical (display-equality guard)", () => { @@ -207,13 +207,13 @@ describe("ItemRow", () => { )); // diff > 60s but both show "3d" — no dot separator span expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); - expect(screen.getByTitle(`Created: ${createdAt}`).textContent).toBe("3d"); + expect(screen.getByTitle(`Created: ${new Date(createdAt).toLocaleString()}`).textContent).toBe("3d"); }); it("shows verbose aria-label for created and updated spans", () => { render(() => ); - const createdSpan = screen.getByTitle(`Created: ${defaultProps.createdAt}`); - const updatedSpan = screen.getByTitle(`Updated: ${defaultProps.updatedAt}`); + const createdSpan = screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`); + const updatedSpan = screen.getByTitle(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`); expect(createdSpan.getAttribute("aria-label")).toMatch(/^Created 2 hours? ago$/); expect(updatedSpan.getAttribute("aria-label")).toMatch(/^Updated 30 minutes? ago$/); }); @@ -231,12 +231,12 @@ describe("ItemRow", () => { refreshTick={tick()} /> )); - expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("2h"); + expect(screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`).textContent).toBe("2h"); // Advance mock time by 3 hours and bump refreshTick mockNow = MOCK_NOW + 3 * 60 * 60 * 1000; setTick(1); - expect(screen.getByTitle(`Created: ${defaultProps.createdAt}`).textContent).toBe("5h"); + expect(screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`).textContent).toBe("5h"); }); }); From 4176ceadaa2394a5cda610c635c6c76765e9b06d Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:22:47 -0400 Subject: [PATCH 05/19] test: future timestamp guard and refreshTick updatedDisplay coverage --- tests/components/ItemRow.test.tsx | 5 ++++- tests/lib/format.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 91859c27..82a8360c 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -224,19 +224,22 @@ describe("ItemRow", () => { vi.spyOn(Date, "now").mockImplementation(() => mockNow); // createdAt is 2h before MOCK_NOW → displays "2h" + // updatedAt is 30m before MOCK_NOW → displays "30m" render(() => ( )); expect(screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`).textContent).toBe("2h"); + expect(screen.getByTitle(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`).textContent).toBe("30m"); // Advance mock time by 3 hours and bump refreshTick mockNow = MOCK_NOW + 3 * 60 * 60 * 1000; setTick(1); expect(screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`).textContent).toBe("5h"); + // updatedAt was 30m before MOCK_NOW; after +3h it is 3h30m ago → Math.floor(210/60) = 3 → "3h" + expect(screen.getByTitle(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`).textContent).toBe("3h"); }); }); diff --git a/tests/lib/format.test.ts b/tests/lib/format.test.ts index b6ccacd1..d8ee82fe 100644 --- a/tests/lib/format.test.ts +++ b/tests/lib/format.test.ts @@ -106,6 +106,11 @@ describe("shortRelativeTime", () => { expect(shortRelativeTime(isoString)).toBe("now"); }); + it("returns 'now' for future timestamps beyond 60s", () => { + const future = new Date(MOCK_NOW + 5 * 60 * 1000).toISOString(); + expect(shortRelativeTime(future)).toBe("now"); + }); + it("returns empty string for invalid input", () => { expect(shortRelativeTime("not-a-date")).toBe(""); expect(shortRelativeTime("")).toBe(""); From cf0877e71b67d5b62f2b55f2a94f0d853640df9f Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:25:59 -0400 Subject: [PATCH 06/19] refactor(ui): simplifies hasUpdate memo with destructured timeInfo --- src/app/components/dashboard/ItemRow.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 89a3af17..2eb67265 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -38,8 +38,8 @@ export default function ItemRow(props: ItemRowProps) { const hasUpdate = createMemo(() => { const diff = new Date(props.updatedAt).getTime() - new Date(props.createdAt).getTime(); if (diff <= 60_000) return false; - if (timeInfo().created === "" || timeInfo().updated === "") return false; - return timeInfo().created !== timeInfo().updated; + const { created, updated } = timeInfo(); + return created !== "" && updated !== "" && created !== updated; }); return ( From fb78368457bc1634f9b6d709b47c05e0bfae4663 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:40:07 -0400 Subject: [PATCH 07/19] refactor(ui): consolidates timeInfo memo and adds boundary test --- src/app/components/dashboard/ItemRow.tsx | 14 ++++++++------ tests/components/ItemRow.test.tsx | 14 +++++++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 2eb67265..d6f35403 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -33,12 +33,14 @@ export default function ItemRow(props: ItemRowProps) { const updated = shortRelativeTime(props.updatedAt); const createdLabel = `Created ${relativeTime(props.createdAt)}`; const updatedLabel = `Updated ${relativeTime(props.updatedAt)}`; - return { created, updated, createdLabel, updatedLabel }; + 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 { created, updated, createdLabel, updatedLabel, createdTitle, updatedTitle, diffMs }; }); const hasUpdate = createMemo(() => { - const diff = new Date(props.updatedAt).getTime() - new Date(props.createdAt).getTime(); - if (diff <= 60_000) return false; - const { created, updated } = timeInfo(); + const { diffMs, created, updated } = timeInfo(); + if (diffMs <= 60_000) return false; return created !== "" && updated !== "" && created !== updated; }); @@ -121,7 +123,7 @@ export default function ItemRow(props: ItemRowProps) { {timeInfo().created} @@ -129,7 +131,7 @@ export default function ItemRow(props: ItemRowProps) { {timeInfo().updated} diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 82a8360c..709409f0 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -49,7 +49,7 @@ describe("ItemRow", () => { // Should show compact format like "2h" const timeEl = screen.getByTitle(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`); expect(timeEl).toBeDefined(); - expect(timeEl.textContent).toMatch(/^\d+h$/); + expect(timeEl.textContent).toBe("2h"); }); it("renders children slot when provided", () => { @@ -198,6 +198,18 @@ describe("ItemRow", () => { expect(screen.queryByTitle(`Updated: ${new Date("2026-03-30T11:59:30Z").toLocaleString()}`)).toBeNull(); }); + it("shows single date when updatedAt is exactly 60s after createdAt", () => { + const { container } = render(() => ( + + )); + // diff === 60_000ms, condition is <=, so still suppressed + expect(container.querySelector('span[aria-hidden="true"]')).toBeNull(); + }); + it("shows single date when both compact values are identical (display-equality guard)", () => { // Both 3 days ago — createdAt 3d+2min ago, updatedAt exactly 3d ago, both display "3d" const createdAt = new Date(MOCK_NOW - (3 * 24 * 60 * 60 + 2 * 60) * 1000).toISOString(); From 0862c3f7e1d3b446aa8bf04ead81e463bc7429f9 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 30 Mar 2026 21:46:33 -0400 Subject: [PATCH 08/19] fix(a11y): uses semantic time elements for date display --- src/app/components/dashboard/ItemRow.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index d6f35403..8aedf92f 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -122,20 +122,22 @@ export default function ItemRow(props: ItemRowProps) {
{props.surfacedByBadge}
- {timeInfo().created} - + - {timeInfo().updated} - + From 5cd523d55de86f95326eeb1998762214a519edc7 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 31 Mar 2026 07:59:09 -0400 Subject: [PATCH 09/19] 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