FN-6618: consolidate dashboard relative-time formatting

Centralize duplicated dashboard relative-time bucket logic while preserving surface-specific copy.

- Route activity log, mailbox, git manager, project card, and task detail timestamps through the shared relative-time helper.
- Keep specialized freshness labels local where seconds granularity or prefixed sync/check status copy differs from the generic helper.
- Extend component tests to cover the preserved timestamp wording and invalid/future-date behavior.

Files changed:
 .../dashboard/app/components/ActivityLogModal.tsx  | 36 ++++++++++------
 .../app/components/AgentReflectionsTab.tsx         |  7 ++-
 .../dashboard/app/components/GitManagerModal.tsx   | 34 ++++++++++-----
 packages/dashboard/app/components/MailboxModal.tsx | 38 ++++++++++------
 packages/dashboard/app/components/MailboxView.tsx  | 38 ++++++++++------
 packages/dashboard/app/components/PrChecksList.tsx |  4 ++
 packages/dashboard/app/components/ProjectCard.tsx  | 35 ++++++++++-----
 packages/dashboard/app/components/RoutineCard.tsx  |  3 ++
 .../dashboard/app/components/TaskDetailModal.tsx   | 37 ++++++++++------
 .../components/__tests__/ActivityLogModal.test.tsx | 41 +++++++++++++++++-
 .../components/__tests__/GitManagerModal.test.tsx  | 36 +++++++++++++++-
 .../app/components/__tests__/MailboxModal.test.tsx | 35 ++++++++++++++-
 .../app/components/__tests__/MailboxView.test.tsx  | 35 ++++++++++++++-
 .../app/components/__tests__/ProjectCard.test.tsx  | 50 +++++++++++++++++++++-
 .../__tests__/TaskDetailModal.rendering.test.tsx   | 44 +++++++++++++++++++
 .../dashboard/app/hooks/useNodeSettingsSync.ts     |  3 ++
 16 files changed, 398 insertions(+), 78 deletions(-)

Fusion-Task-Id: FN-6618

Fusion-Task-Lineage: ac24de7c-3333-43f7-83c1-aa4c2a3dbe31
This commit is contained in:
gsxdsm
2026-06-17 21:37:18 -07:00
parent cee24b8b8d
commit ae3e57572f
16 changed files with 395 additions and 75 deletions

View File

@@ -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<ActivityEventType, React.ReactNode> = {
};
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" });
}
}
/**

View File

@@ -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, unknown>) => string): string {
const now = Date.now();
const then = new Date(iso).getTime();

View File

@@ -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 ─────────────────────────────────────────────────────────

View File

@@ -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(

View File

@@ -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(

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
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([
{

View File

@@ -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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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 () => {

View File

@@ -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(<MailboxModal {...defaultProps} />);
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: [

View File

@@ -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(<MailboxView {...defaultProps} />);
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: [],

View File

@@ -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> = {}): 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]) => (
<ProjectCard
key={id}
project={makeProject({ id, lastActivityAt: timestamp })}
health={makeHealth({ projectId: id, lastActivityAt: timestamp })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
))}
<ProjectCard
project={makeProject({ id: "never", lastActivityAt: undefined })}
health={makeHealth({ projectId: "never", lastActivityAt: undefined })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
</>,
);
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();

View File

@@ -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(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-11T11:59:30.000Z",
updatedAt: "2026-05-11T11:55:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
let timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created just now");
expect(timestamps).toHaveTextContent("Updated 5m ago");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "not-a-date",
updatedAt: "2026-05-11T12:00:01.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created Invalid Date");
expect(timestamps).toHaveTextContent("Updated just now");
});
});
});

View File

@@ -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) {