diff --git a/packages/dashboard/app/components/ActivityLogModal.tsx b/packages/dashboard/app/components/ActivityLogModal.tsx index a43d25d1f9..d649465105 100644 --- a/packages/dashboard/app/components/ActivityLogModal.tsx +++ b/packages/dashboard/app/components/ActivityLogModal.tsx @@ -9,6 +9,7 @@ import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type A import { useActivityLog } from "../hooks/useActivityLog"; import type { Task, ProjectInfo } from "@fusion/core"; import { linkifyFilePaths } from "../utils/filePathLinkify"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; interface ActivityLogModalProps { isOpen: boolean; @@ -70,19 +71,30 @@ const EVENT_TYPE_ICONS: Record = { }; function formatTimestamp(timestamp: string, t: TFunction<"app">): string { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 centralizes ActivityLogModal bucket math without changing its activityLog.time.* keys, uppercase Just now default, future-as-just-now behavior, or Invalid Date fallback. + */ + const bucket = getRelativeTimeBucket(timestamp); + if (!bucket) { + const timestampMs = Date.parse(timestamp); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t("activityLog.time.justNow", "Just now"); + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } - if (diffMins < 1) return t("activityLog.time.justNow", "Just now"); - if (diffMins < 60) return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: diffMins }); - if (diffHours < 24) return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: diffHours }); - if (diffDays < 7) return t("activityLog.time.daysAgo", "{{count}}d ago", { count: diffDays }); - - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + switch (bucket.bucket) { + case "just-now": + return t("activityLog.time.justNow", "Just now"); + case "minutes": + return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: bucket.count }); + case "hours": + return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: bucket.count }); + case "days": + return t("activityLog.time.daysAgo", "{{count}}d ago", { count: bucket.count }); + case "weeks": + case "older": + return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } } /** diff --git a/packages/dashboard/app/components/AgentReflectionsTab.tsx b/packages/dashboard/app/components/AgentReflectionsTab.tsx index 9ad8dda1a9..3048f21074 100644 --- a/packages/dashboard/app/components/AgentReflectionsTab.tsx +++ b/packages/dashboard/app/components/AgentReflectionsTab.tsx @@ -48,7 +48,12 @@ function formatPercent(rate: number): string { return `${Math.round(rate * 100)}%`; } -/** Format an ISO timestamp to a relative time string */ +/** + * Format an ISO timestamp to a relative time string. + * + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 intentionally leaves AgentReflectionsTab local because its `agents.time.in*` future-time i18n outputs would be lost if getRelativeTimeBucket's negative-diff null were treated as an invalid timestamp. + */ function relativeTime(iso: string, t: (key: string, defaultValue: string, opts?: Record) => string): string { const now = Date.now(); const then = new Date(iso).getTime(); diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index 3720dd8052..30df55031e 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -23,6 +23,7 @@ import type { GitFileChange, GitRemoteDetailed, } from "../api"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { api, fetchConfig, @@ -155,21 +156,32 @@ function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) { ); } -/** Format relative date. Returns "—" for invalid/empty dates. */ +/** + * Format relative date. Returns "—" for invalid/empty dates. + * + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 shares relative-time bucket math while preserving GitManagerModal's empty/invalid "—" guard, future-as-just-now behavior, and <30d day threshold keyed from total days. + */ function relativeDate(dateStr: string | undefined | null): string { if (!dateStr) return "—"; const date = new Date(dateStr); if (isNaN(date.getTime())) return "—"; - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 30) return `${diffDays}d ago`; - return date.toLocaleDateString(); + + const bucket = getRelativeTimeBucket(dateStr); + if (!bucket) return "just now"; + + switch (bucket.bucket) { + case "just-now": + return "just now"; + case "minutes": + return `${bucket.count}m ago`; + case "hours": + return `${bucket.count}h ago`; + case "days": + case "weeks": + case "older": + return bucket.days < 30 ? `${bucket.days}d ago` : date.toLocaleDateString(); + } } // ── Props ───────────────────────────────────────────────────────── diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 635e9975c8..672002fb96 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -42,6 +42,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useViewportMode } from "./Header"; import { subscribeSse } from "../sse-bus"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; // ── Types ───────────────────────────────────────────────────────────────── @@ -60,19 +61,30 @@ interface MailboxModalProps { // ── Helpers ─────────────────────────────────────────────────────────────── function formatTimestamp(ts: string, t?: TFunction<"app">): string { - const date = new Date(ts); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 reuses shared bucket math while preserving MailboxModal's optional-t fallbacks, future-as-Just-now behavior, and Invalid Date fallback. + */ + const bucket = getRelativeTimeBucket(ts); + if (!bucket) { + const timestampMs = Date.parse(ts); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t?.("mailbox.timeJustNow", "Just now") ?? "Just now"; + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } - if (diffMins < 1) return t?.("mailbox.timeJustNow", "Just now") ?? "Just now"; - if (diffMins < 60) return t?.("mailbox.timeMinsAgo", "{{count}}m ago", { count: diffMins }) ?? `${diffMins}m ago`; - if (diffHours < 24) return t?.("mailbox.timeHoursAgo", "{{count}}h ago", { count: diffHours }) ?? `${diffHours}h ago`; - if (diffDays < 7) return t?.("mailbox.timeDaysAgo", "{{count}}d ago", { count: diffDays }) ?? `${diffDays}d ago`; - - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + switch (bucket.bucket) { + case "just-now": + return t?.("mailbox.timeJustNow", "Just now") ?? "Just now"; + case "minutes": + return t?.("mailbox.timeMinsAgo", "{{count}}m ago", { count: bucket.count }) ?? `${bucket.count}m ago`; + case "hours": + return t?.("mailbox.timeHoursAgo", "{{count}}h ago", { count: bucket.count }) ?? `${bucket.count}h ago`; + case "days": + return t?.("mailbox.timeDaysAgo", "{{count}}d ago", { count: bucket.count }) ?? `${bucket.count}d ago`; + case "weeks": + case "older": + return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } } function participantLabel( diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index f4c43df12b..97596dbf3c 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -44,6 +44,7 @@ import { subscribeSse } from "../sse-bus"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; // ── Types ───────────────────────────────────────────────────────────────── @@ -90,19 +91,30 @@ function readMailboxSidebarWidth(projectId?: string): number { // ── Helpers ─────────────────────────────────────────────────────────────── function formatTimestamp(ts: string, t?: TFunction<"app">): string { - const date = new Date(ts); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 shares bucket math while preserving MailboxView's composed count + mailbox.ago i18n shape, future-as-Just-now behavior, and Invalid Date fallback. + */ + const bucket = getRelativeTimeBucket(ts); + if (!bucket) { + const timestampMs = Date.parse(ts); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t ? t("mailbox.justNow", "Just now") : "Just now"; + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } - if (diffMins < 1) return t ? t("mailbox.justNow", "Just now") : "Just now"; - if (diffMins < 60) return `${diffMins}m ${t ? t("mailbox.ago", "ago") : "ago"}`; - if (diffHours < 24) return `${diffHours}h ${t ? t("mailbox.ago", "ago") : "ago"}`; - if (diffDays < 7) return `${diffDays}d ${t ? t("mailbox.ago", "ago") : "ago"}`; - - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + switch (bucket.bucket) { + case "just-now": + return t ? t("mailbox.justNow", "Just now") : "Just now"; + case "minutes": + return `${bucket.count}m ${t ? t("mailbox.ago", "ago") : "ago"}`; + case "hours": + return `${bucket.count}h ${t ? t("mailbox.ago", "ago") : "ago"}`; + case "days": + return `${bucket.count}d ${t ? t("mailbox.ago", "ago") : "ago"}`; + case "weeks": + case "older": + return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } } function participantLabel( diff --git a/packages/dashboard/app/components/PrChecksList.tsx b/packages/dashboard/app/components/PrChecksList.tsx index 07b9539956..c4cdf3a3a4 100644 --- a/packages/dashboard/app/components/PrChecksList.tsx +++ b/packages/dashboard/app/components/PrChecksList.tsx @@ -34,6 +34,10 @@ function formatDuration(startedAt?: string, completedAt?: string): string | null return `${String(mins).padStart(2, "0")}:${String(rem).padStart(2, "0")}`; } +/* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 keeps PR check freshness local because this surface requires seconds-granularity `updated Ns ago` copy and null for empty/unparseable values, which getRelativeTimeBucket does not express. + */ function relativeTime(value?: string): string | null { if (!value) return null; const ts = Date.parse(value); diff --git a/packages/dashboard/app/components/ProjectCard.tsx b/packages/dashboard/app/components/ProjectCard.tsx index e2b407c162..9f9b821eb2 100644 --- a/packages/dashboard/app/components/ProjectCard.tsx +++ b/packages/dashboard/app/components/ProjectCard.tsx @@ -6,6 +6,7 @@ import "./ProjectCard.css"; import type { RegisteredProject, ProjectHealth } from "@fusion/core"; import type { ProjectNodeAvailability } from "../api"; import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; export interface ProjectCardProps { project: RegisteredProject; @@ -21,18 +22,30 @@ export interface ProjectCardProps { function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): string { if (!timestamp) return t("projectCard.never", "Never"); - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 reuses shared relative-time buckets while preserving ProjectCard's Never guard, projectCard.* i18n keys, future-as-Just-now behavior, and no-options date fallback. + */ + const bucket = getRelativeTimeBucket(timestamp); + if (!bucket) { + const timestampMs = Date.parse(timestamp); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t("projectCard.justNow", "Just now"); + return new Date(timestamp).toLocaleDateString(); + } - if (diffMins < 1) return t("projectCard.justNow", "Just now"); - if (diffMins < 60) return t("projectCard.minutesAgo", "{{count}}m ago", { count: diffMins }); - if (diffHours < 24) return t("projectCard.hoursAgo", "{{count}}h ago", { count: diffHours }); - if (diffDays < 7) return t("projectCard.daysAgo", "{{count}}d ago", { count: diffDays }); - return date.toLocaleDateString(); + switch (bucket.bucket) { + case "just-now": + return t("projectCard.justNow", "Just now"); + case "minutes": + return t("projectCard.minutesAgo", "{{count}}m ago", { count: bucket.count }); + case "hours": + return t("projectCard.hoursAgo", "{{count}}h ago", { count: bucket.count }); + case "days": + return t("projectCard.daysAgo", "{{count}}d ago", { count: bucket.count }); + case "weeks": + case "older": + return bucket.date.toLocaleDateString(); + } } function truncatePath(path: string, maxLength: number = 40): string { diff --git a/packages/dashboard/app/components/RoutineCard.tsx b/packages/dashboard/app/components/RoutineCard.tsx index 574c182980..17e800a8d1 100644 --- a/packages/dashboard/app/components/RoutineCard.tsx +++ b/packages/dashboard/app/components/RoutineCard.tsx @@ -21,6 +21,9 @@ function formatDurationMs(ms: number): string { /** * Format an ISO timestamp to a relative time string. + * + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 deliberately keeps RoutineCard separate from getRelativeTimeBucket because scheduled routines expose future-time copy (`in a moment`, `in Xm`, `in Xh`, `in Xd`) that the shared helper represents as null. */ function relativeTime(iso: string): string { const now = Date.now(); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index a3a402372b..489cc3b2d5 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -62,6 +62,7 @@ import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../u import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; interface ModelSelection { provider?: string; @@ -256,18 +257,30 @@ function getStepStatusColor(status: string): string { } function formatTimestamp(iso: string): string { - const date = new Date(iso); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMin = Math.floor(diffMs / 60000); - const diffHr = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHr / 24); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 routes TaskDetailModal timestamp math through getRelativeTimeBucket while preserving lowercase compact labels, future-as-just-now behavior, and the legacy Invalid Date fallback for unparseable input. + */ + const bucket = getRelativeTimeBucket(iso); + if (!bucket) { + const timestampMs = Date.parse(iso); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return "just now"; + return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } - if (diffMin < 1) return "just now"; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHr < 24) return `${diffHr}h ago`; - if (diffDay < 7) return `${diffDay}d ago`; - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + switch (bucket.bucket) { + case "just-now": + return "just now"; + case "minutes": + return `${bucket.count}m ago`; + case "hours": + return `${bucket.count}h ago`; + case "days": + return `${bucket.count}d ago`; + case "weeks": + case "older": + return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } } function formatBytes(bytes: number): string { diff --git a/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx b/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx index 6b3829d4a5..567842751e 100644 --- a/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ActivityLogModal.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ActivityLogModal } from "../ActivityLogModal"; import * as apiModule from "../../api"; @@ -16,6 +16,10 @@ const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog); const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog); describe("ActivityLogModal", () => { + afterEach(() => { + vi.useRealTimers(); + }); + const mockOnClose = vi.fn(); const mockOnOpenTaskDetail = vi.fn(); @@ -113,6 +117,41 @@ describe("ActivityLogModal", () => { }); }); + it("preserves byte-identical relative timestamp buckets", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z")); + mockFetchActivityLog.mockResolvedValueOnce([ + { id: "now", timestamp: "2026-06-17T19:59:30.000Z", type: "task:created", details: "now" }, + { id: "minute", timestamp: "2026-06-17T19:55:00.000Z", type: "task:created", details: "minute" }, + { id: "hour", timestamp: "2026-06-17T17:00:00.000Z", type: "task:created", details: "hour" }, + { id: "day", timestamp: "2026-06-14T20:00:00.000Z", type: "task:created", details: "day" }, + { id: "future", timestamp: "2026-06-17T20:00:01.000Z", type: "task:created", details: "future" }, + { id: "invalid", timestamp: "not-a-date", type: "task:created", details: "invalid" }, + { id: "older", timestamp: "2026-06-10T20:00:00.000Z", type: "task:created", details: "older" }, + ] as ActivityLogEntry[]); + + const { container } = render( + + ); + + await waitFor(() => { + const times = Array.from(container.querySelectorAll(".activity-log-entry-time")).map((node) => node.textContent); + expect(times).toEqual(expect.arrayContaining([ + "Just now", + "5m ago", + "3h ago", + "3d ago", + "Invalid Date", + new Date("2026-06-10T20:00:00.000Z").toLocaleDateString(undefined, { month: "short", day: "numeric" }), + ])); + }); + }); + it("renders labels and icons for auto-archived event types", async () => { mockFetchActivityLog.mockResolvedValueOnce([ { diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index a17e4b4829..53a99973b2 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GitManagerModal } from "../GitManagerModal"; @@ -149,6 +149,10 @@ const mockTasks: Task[] = [ ]; describe("GitManagerModal", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); mockUseViewportMode.mockReturnValue("desktop"); @@ -1144,6 +1148,36 @@ describe("GitManagerModal", () => { }); }); + it("preserves branch relative-date buckets including the 30 day threshold", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z")); + (fetchGitBranches as any).mockResolvedValue([ + { name: "now", isCurrent: true, lastCommitDate: "2026-06-17T19:59:30.000Z" }, + { name: "minutes", isCurrent: false, lastCommitDate: "2026-06-17T19:55:00.000Z" }, + { name: "hours", isCurrent: false, lastCommitDate: "2026-06-17T17:00:00.000Z" }, + { name: "twenty-nine", isCurrent: false, lastCommitDate: "2026-05-19T20:00:00.000Z" }, + { name: "thirty", isCurrent: false, lastCommitDate: "2026-05-18T20:00:00.000Z" }, + { name: "future", isCurrent: false, lastCommitDate: "2026-06-17T20:00:01.000Z" }, + ]); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /branches/i })); + + await waitFor(() => { + const panel = screen.getByTestId("branches-panel"); + const dateTexts = Array.from(panel.querySelectorAll(".gm-branch-date")).map((node) => node.textContent); + expect(dateTexts).toEqual(expect.arrayContaining([ + "just now", + "5m ago", + "3h ago", + "29d ago", + new Date("2026-05-18T20:00:00.000Z").toLocaleDateString(), + ])); + }); + }); + // ── Worktrees Panel ──────────────────────────────────────── it("loads worktrees and shows task associations", async () => { diff --git a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx index f2b0bfb4cb..4264f7fa97 100644 --- a/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxModal.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { loadAllAppCss } from "../../test/cssFixture"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { MailboxModal } from "../MailboxModal"; @@ -127,6 +127,10 @@ const defaultProps = { }; describe("MailboxModal", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); // Clear SWR cache between tests so prior runs don't pre-hydrate inbox/outbox @@ -213,6 +217,35 @@ describe("MailboxModal", () => { }); }); + it("preserves byte-identical inbox timestamp buckets", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z")); + const messages = [ + ["now", "2026-06-17T19:59:30.000Z"], + ["minute", "2026-06-17T19:55:00.000Z"], + ["hour", "2026-06-17T17:00:00.000Z"], + ["day", "2026-06-14T20:00:00.000Z"], + ["future", "2026-06-17T20:00:01.000Z"], + ["invalid", "not-a-date"], + ["older", "2026-06-10T20:00:00.000Z"], + ].map(([id, createdAt]) => ({ ...mockMessage, id: `msg-${id}`, createdAt, updatedAt: createdAt, content: id, read: true })); + mockFetchInbox.mockResolvedValue({ messages, total: messages.length, unreadCount: 0 }); + + const { container } = render(); + + await waitFor(() => { + const times = Array.from(container.querySelectorAll(".mailbox-item-time")).map((node) => node.textContent); + expect(times).toEqual(expect.arrayContaining([ + "Just now", + "5m ago", + "3h ago", + "3d ago", + "Invalid Date", + new Date("2026-06-10T20:00:00.000Z").toLocaleDateString(undefined, { month: "short", day: "numeric" }), + ])); + }); + }); + it("renders agent participant labels with name and id, then falls back to id", async () => { mockFetchInbox.mockResolvedValue({ messages: [ diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index fa466cae69..eb8bbe1150 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { loadAllAppCss } from "../../test/cssFixture"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { MailboxView } from "../MailboxView"; @@ -175,6 +175,10 @@ function makeOutboxResponse(messages: Message[]) { } describe("MailboxView", () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); @@ -219,6 +223,35 @@ describe("MailboxView", () => { }); }); + it("preserves composed inbox timestamp buckets", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z")); + const messages = [ + ["now", "2026-06-17T19:59:30.000Z"], + ["minute", "2026-06-17T19:55:00.000Z"], + ["hour", "2026-06-17T17:00:00.000Z"], + ["day", "2026-06-14T20:00:00.000Z"], + ["future", "2026-06-17T20:00:01.000Z"], + ["invalid", "not-a-date"], + ["older", "2026-06-10T20:00:00.000Z"], + ].map(([id, createdAt]) => ({ ...mockMessage, id: `msg-${id}`, createdAt, updatedAt: createdAt, content: id, read: true })); + mockFetchInbox.mockResolvedValue(makeInboxResponse(messages, 0)); + + const { container } = render(); + + await waitFor(() => { + const times = Array.from(container.querySelectorAll(".mailbox-item-time")).map((node) => node.textContent); + expect(times).toEqual(expect.arrayContaining([ + "Just now", + "5m ago", + "3h ago", + "3d ago", + "Invalid Date", + new Date("2026-06-10T20:00:00.000Z").toLocaleDateString(undefined, { month: "short", day: "numeric" }), + ])); + }); + }); + it("renders all four tabs", async () => { mockFetchInbox.mockResolvedValue({ messages: [], diff --git a/packages/dashboard/app/components/__tests__/ProjectCard.test.tsx b/packages/dashboard/app/components/__tests__/ProjectCard.test.tsx index cb4ff795ea..32654a3e09 100644 --- a/packages/dashboard/app/components/__tests__/ProjectCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/ProjectCard.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { ProjectCard } from "../ProjectCard"; import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core"; @@ -43,6 +43,10 @@ function makeHealth(overrides: Partial = {}): ProjectHealth { const noop = () => {}; +afterEach(() => { + vi.useRealTimers(); +}); + describe("ProjectCard", () => { it("renders project name and path", () => { render( @@ -276,6 +280,50 @@ describe("ProjectCard", () => { expect(screen.getByText("Never")).toBeDefined(); }); + it("preserves byte-identical relative time output buckets for last activity", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z")); + + const cases = [ + ["under-minute", "2026-06-17T19:59:30.000Z", "Just now"], + ["minute", "2026-06-17T19:55:00.000Z", "5m ago"], + ["hour", "2026-06-17T17:00:00.000Z", "3h ago"], + ["day", "2026-06-14T20:00:00.000Z", "3d ago"], + ["future", "2026-06-17T20:00:01.000Z", "Just now"], + ["invalid", "not-a-date", "Invalid Date"], + ["older", "2026-06-10T20:00:00.000Z", new Date("2026-06-10T20:00:00.000Z").toLocaleDateString()], + ] as const; + + render( + <> + {cases.map(([id, timestamp]) => ( + + ))} + + , + ); + + for (const [, , expected] of cases) { + expect(screen.getAllByText(expected).length).toBeGreaterThan(0); + } + expect(screen.getByText("Never")).toBeDefined(); + }); + it("calls onSelect when card is clicked", () => { const onSelect = vi.fn(); const project = makeProject(); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index c5dc3d8a8f..23dde5df20 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -380,6 +380,50 @@ describe("TaskDetailModal", () => { expect(timestamps).toHaveTextContent("Created May 1"); expect(timestamps).toHaveTextContent("Updated May 2"); }); + + it("preserves byte-identical timestamp buckets and edge cases", () => { + const { rerender } = render( + , + ); + + let timestamps = screen.getByLabelText("Task timestamps"); + expect(timestamps).toHaveTextContent("Created just now"); + expect(timestamps).toHaveTextContent("Updated 5m ago"); + + rerender( + , + ); + + timestamps = screen.getByLabelText("Task timestamps"); + expect(timestamps).toHaveTextContent("Created Invalid Date"); + expect(timestamps).toHaveTextContent("Updated just now"); + }); }); }); diff --git a/packages/dashboard/app/hooks/useNodeSettingsSync.ts b/packages/dashboard/app/hooks/useNodeSettingsSync.ts index 4511423806..cff217dbd0 100644 --- a/packages/dashboard/app/hooks/useNodeSettingsSync.ts +++ b/packages/dashboard/app/hooks/useNodeSettingsSync.ts @@ -54,6 +54,9 @@ export function computeSyncState(status: NodeSettingsSyncStatus): ComputedNodeSy /** * Format a relative time string from an ISO timestamp. * Returns "Synced Xm ago", "Synced Xh ago", "Synced Xd ago", or "Never synced". + * + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 keeps node settings sync timestamps local because the user-facing contract is the prefixed `Synced … ago` / `Never synced` state copy, not a generic relative-time label. */ export function formatRelativeTime(isoTimestamp: string | null): string { if (isoTimestamp === null) {