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:
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user