FN-6601: consolidate relative-time bucket formatting
Consolidate dashboard relative-time calculations behind a shared bucket helper while preserving each surface's display behavior. - Add getRelativeTimeBucket to centralize parsing, boundary math, and bucket metadata. - Route ActivityFeed, AgentLogViewer, MissionManager, and PlanningModeModal timestamp labels through the shared helper. - Extend unit coverage for bucket boundaries plus surface-specific just-now and future timestamp behavior. Files changed: packages/dashboard/app/components/ActivityFeed.tsx | 36 ++++++---- .../dashboard/app/components/AgentLogViewer.tsx | 36 ++++++---- .../dashboard/app/components/MissionManager.tsx | 32 +++++---- .../dashboard/app/components/PlanningModeModal.tsx | 35 ++++++---- .../app/components/__tests__/ActivityFeed.test.tsx | 8 +++ .../components/__tests__/AgentLogViewer.test.tsx | 8 +++ .../app/utils/__tests__/relativeTimeAgo.test.ts | 77 +++++++++++++++++++++- packages/dashboard/app/utils/relativeTimeAgo.ts | 66 +++++++++++++++---- 8 files changed, 236 insertions(+), 62 deletions(-) Fusion-Task-Id: FN-6601 Fusion-Task-Lineage: 3521afa0-f3ce-49a4-8861-efd4f64f9a69
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
export interface ActivityFeedProps {
|
||||
entries: ActivityFeedEntry[];
|
||||
@@ -48,19 +49,30 @@ const TYPE_CONFIG: Record<ActivityFeedEntry["type"], {
|
||||
"project:isolation-transition": { label: "Isolation", icon: Folder, color: "var(--color-info)" },
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:ActivityFeedTimestamps 2026-06-17-17:27:
|
||||
FN-6601 routes ActivityFeed through the shared relative-time bucket helper while preserving this surface's capitalized "Just now" label and locale-date fallback.
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string): 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);
|
||||
const bucket = getRelativeTimeBucket(timestamp);
|
||||
if (!bucket) {
|
||||
const date = new Date(timestamp);
|
||||
return Number.isFinite(date.getTime()) ? "Just now" : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullTime(timestamp: string): string {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Components } from "react-markdown";
|
||||
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import "./AgentLogViewer.css";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown";
|
||||
const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output";
|
||||
@@ -33,19 +34,30 @@ function writeBooleanPref(key: string, value: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentLogTimestamps 2026-06-17-17:34:
|
||||
FN-6601 centralizes timestamp bucket math but AgentLog keeps its existing translation keys and future timestamps continue to render as "just now".
|
||||
*/
|
||||
function formatTimestamp(iso: string, t: TFunction<"app">): 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);
|
||||
const bucket = getRelativeTimeBucket(iso);
|
||||
if (!bucket) {
|
||||
const date = new Date(iso);
|
||||
return Number.isFinite(date.getTime()) ? t("agentLog.timeJustNow", "just now") : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (diffMin < 1) return t("agentLog.timeJustNow", "just now");
|
||||
if (diffMin < 60) return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: diffMin });
|
||||
if (diffHr < 24) return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: diffHr });
|
||||
if (diffDay < 7) return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: diffDay });
|
||||
return date.toLocaleDateString();
|
||||
switch (bucket.bucket) {
|
||||
case "just-now":
|
||||
return t("agentLog.timeJustNow", "just now");
|
||||
case "minutes":
|
||||
return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: bucket.count });
|
||||
case "hours":
|
||||
return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: bucket.count });
|
||||
case "days":
|
||||
return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: bucket.count });
|
||||
case "weeks":
|
||||
case "older":
|
||||
return bucket.date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
export const markdownComponents: Components = {
|
||||
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
} from "../api";
|
||||
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
|
||||
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
|
||||
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
|
||||
const MISSION_SIDEBAR_MIN_WIDTH = 220;
|
||||
@@ -396,24 +397,31 @@ type MissionHealthState = "healthy" | "warning" | "error";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/*
|
||||
FNXC:MissionTimestamps 2026-06-17-17:34:
|
||||
FN-6601 uses the shared relative-time bucket helper while preserving MissionManager's missing-value em dash and days-forever fallback.
|
||||
*/
|
||||
function getRelativeTime(timestamp: string | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
|
||||
if (!timestamp) return "—";
|
||||
|
||||
const ts = new Date(timestamp).getTime();
|
||||
if (Number.isNaN(ts)) return "—";
|
||||
|
||||
const diffMs = Date.now() - ts;
|
||||
if (diffMs < 0) return t("missions.relativeTimeJustNow", "just now");
|
||||
const bucket = getRelativeTimeBucket(timestamp);
|
||||
if (!bucket) return t("missions.relativeTimeJustNow", "just now");
|
||||
|
||||
const diffMinutes = Math.floor(diffMs / (60 * 1000));
|
||||
if (diffMinutes < 1) return t("missions.relativeTimeJustNow", "just now");
|
||||
if (diffMinutes < 60) return t("missions.relativeTimeMinutes", "{{count}}m ago", { count: diffMinutes });
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return t("missions.relativeTimeHours", "{{count}}h ago", { count: diffHours });
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return t("missions.relativeTimeDays", "{{count}}d ago", { count: diffDays });
|
||||
switch (bucket.bucket) {
|
||||
case "just-now":
|
||||
return t("missions.relativeTimeJustNow", "just now");
|
||||
case "minutes":
|
||||
return t("missions.relativeTimeMinutes", "{{count}}m ago", { count: bucket.count });
|
||||
case "hours":
|
||||
return t("missions.relativeTimeHours", "{{count}}h ago", { count: bucket.count });
|
||||
case "days":
|
||||
case "weeks":
|
||||
case "older":
|
||||
return t("missions.relativeTimeDays", "{{count}}d ago", { count: bucket.days });
|
||||
}
|
||||
}
|
||||
|
||||
function getMissionHealthState(health?: MissionHealth): MissionHealthState {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
getPlanningDescription,
|
||||
clearPlanningDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
@@ -3279,18 +3280,26 @@ function PlanningSessionStatusLabel({ status }: { status: AiSessionSummary["stat
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningTimestamps 2026-06-17-17:34:
|
||||
FN-6601 shares relative-time bucket math while preserving Planning Mode's empty invalid/future fallback and weeks-specific translation branch.
|
||||
*/
|
||||
function formatRelativeTime(iso: string, t: TFunction<"app">): string {
|
||||
const ms = Date.now() - Date.parse(iso);
|
||||
if (!Number.isFinite(ms) || ms < 0) return "";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return t("planning.relativeTimeJustNow", "just now");
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return t("planning.relativeTimeMinutes", "{{count}}m ago", { count: min });
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t("planning.relativeTimeHours", "{{count}}h ago", { count: hr });
|
||||
const days = Math.floor(hr / 24);
|
||||
if (days < 7) return t("planning.relativeTimeDays", "{{count}}d ago", { count: days });
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 4) return t("planning.relativeTimeWeeks", "{{count}}w ago", { count: weeks });
|
||||
return new Date(iso).toLocaleDateString();
|
||||
const bucket = getRelativeTimeBucket(iso);
|
||||
if (!bucket) return "";
|
||||
|
||||
switch (bucket.bucket) {
|
||||
case "just-now":
|
||||
return t("planning.relativeTimeJustNow", "just now");
|
||||
case "minutes":
|
||||
return t("planning.relativeTimeMinutes", "{{count}}m ago", { count: bucket.count });
|
||||
case "hours":
|
||||
return t("planning.relativeTimeHours", "{{count}}h ago", { count: bucket.count });
|
||||
case "days":
|
||||
return t("planning.relativeTimeDays", "{{count}}d ago", { count: bucket.count });
|
||||
case "weeks":
|
||||
return t("planning.relativeTimeWeeks", "{{count}}w ago", { count: bucket.count });
|
||||
case "older":
|
||||
return bucket.date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,14 @@ describe("ActivityFeed", () => {
|
||||
expect(screen.getByText("5m ago")).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves capitalized Just now for entries under one minute old", () => {
|
||||
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString();
|
||||
|
||||
render(<ActivityFeed entries={[makeEntry({ timestamp: thirtySecondsAgo })]} />);
|
||||
|
||||
expect(screen.getByText("Just now")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders different event types with correct labels", () => {
|
||||
const entries: ActivityFeedEntry[] = [
|
||||
makeEntry({ id: "1", type: "task:created" }),
|
||||
|
||||
@@ -70,6 +70,14 @@ describe("AgentLogViewer", () => {
|
||||
expect(textSpans[0].textContent).toContain("first chunk second chunk");
|
||||
});
|
||||
|
||||
it("preserves just now output for future timestamps", () => {
|
||||
const futureTimestamp = new Date(Date.now() + 30_000).toISOString();
|
||||
|
||||
render(<AgentLogViewer entries={[makeEntry({ timestamp: futureTimestamp, agent: "executor" })]} loading={false} />);
|
||||
|
||||
expect(screen.getByTestId("agent-log-timestamp")).toHaveTextContent("just now");
|
||||
});
|
||||
|
||||
it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => {
|
||||
const initialEntries = [
|
||||
makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z", agent: "triage" }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRelativeTimeAgo } from "../relativeTimeAgo";
|
||||
import { formatRelativeTimeAgo, getRelativeTimeBucket } from "../relativeTimeAgo";
|
||||
|
||||
describe("formatRelativeTimeAgo", () => {
|
||||
const now = Date.parse("2026-06-17T15:40:00.000Z");
|
||||
@@ -29,4 +29,79 @@ describe("formatRelativeTimeAgo", () => {
|
||||
expect(formatRelativeTimeAgo("", now)).toBe("");
|
||||
expect(formatRelativeTimeAgo("not-a-date", now)).toBe("");
|
||||
});
|
||||
|
||||
it("preserves future timestamp output as just now", () => {
|
||||
expect(formatRelativeTimeAgo("2026-06-17T15:40:01.000Z", now)).toBe("just now");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRelativeTimeBucket", () => {
|
||||
const now = Date.parse("2026-06-17T15:40:00.000Z");
|
||||
|
||||
it("returns null for empty, unparseable, and future timestamps", () => {
|
||||
expect(getRelativeTimeBucket("", now)).toBeNull();
|
||||
expect(getRelativeTimeBucket("not-a-date", now)).toBeNull();
|
||||
expect(getRelativeTimeBucket("2026-06-17T15:40:01.000Z", now)).toBeNull();
|
||||
});
|
||||
|
||||
it("buckets timestamps under one minute as just-now", () => {
|
||||
expect(getRelativeTimeBucket("2026-06-17T15:39:01.000Z", now)).toMatchObject({
|
||||
bucket: "just-now",
|
||||
count: 0,
|
||||
days: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets the exact one-minute boundary as minutes", () => {
|
||||
expect(getRelativeTimeBucket("2026-06-17T15:39:00.000Z", now)).toMatchObject({
|
||||
bucket: "minutes",
|
||||
count: 1,
|
||||
days: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets the exact one-hour boundary as hours", () => {
|
||||
expect(getRelativeTimeBucket("2026-06-17T14:40:00.000Z", now)).toMatchObject({
|
||||
bucket: "hours",
|
||||
count: 1,
|
||||
days: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets the exact one-day boundary as days", () => {
|
||||
expect(getRelativeTimeBucket("2026-06-16T15:40:00.000Z", now)).toMatchObject({
|
||||
bucket: "days",
|
||||
count: 1,
|
||||
days: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets the exact seven-day boundary as weeks with total days", () => {
|
||||
expect(getRelativeTimeBucket("2026-06-10T15:40:00.000Z", now)).toMatchObject({
|
||||
bucket: "weeks",
|
||||
count: 1,
|
||||
days: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets timestamps just under four weeks as weeks with total days", () => {
|
||||
expect(getRelativeTimeBucket("2026-05-20T15:40:01.000Z", now)).toMatchObject({
|
||||
bucket: "weeks",
|
||||
count: 3,
|
||||
days: 27,
|
||||
});
|
||||
});
|
||||
|
||||
it("buckets the exact four-week boundary as older with total days", () => {
|
||||
expect(getRelativeTimeBucket("2026-05-20T15:40:00.000Z", now)).toMatchObject({
|
||||
bucket: "older",
|
||||
count: 4,
|
||||
days: 28,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the parsed date for locale fallback callers", () => {
|
||||
const iso = "2026-06-01T15:40:00.000Z";
|
||||
expect(getRelativeTimeBucket(iso, now)?.date.toISOString()).toBe(iso);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,65 @@
|
||||
export type RelativeTimeBucket = "just-now" | "minutes" | "hours" | "days" | "weeks" | "older";
|
||||
|
||||
export interface RelativeTimeBucketResult {
|
||||
bucket: RelativeTimeBucket;
|
||||
count: number;
|
||||
days: number;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:RelativeTime 2026-06-17-17:22:
|
||||
* FN-6601 consolidates relative-time bucket math while preserving each surface's existing i18n keys, capitalization, and fallback policy.
|
||||
* Callers map buckets to their local strings instead of sharing rendered copy.
|
||||
*/
|
||||
export function getRelativeTimeBucket(iso: string, now: number = Date.now()): RelativeTimeBucketResult | null {
|
||||
if (!iso) return null;
|
||||
|
||||
const timestampMs = Date.parse(iso);
|
||||
if (!Number.isFinite(timestampMs)) return null;
|
||||
|
||||
const diffMs = now - timestampMs;
|
||||
if (diffMs < 0) return null;
|
||||
|
||||
const diffSeconds = Math.floor(diffMs / 1_000);
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const date = new Date(timestampMs);
|
||||
|
||||
if (diffSeconds < 60) return { bucket: "just-now", count: 0, days: 0, date };
|
||||
if (diffMinutes < 60) return { bucket: "minutes", count: diffMinutes, days: 0, date };
|
||||
if (diffHours < 24) return { bucket: "hours", count: diffHours, days: 0, date };
|
||||
if (diffDays < 7) return { bucket: "days", count: diffDays, days: diffDays, date };
|
||||
if (diffDays < 28) return { bucket: "weeks", count: diffWeeks, days: diffDays, date };
|
||||
|
||||
return { bucket: "older", count: diffWeeks, days: diffDays, date };
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:TaskChatTimestamps 2026-06-17-15:40:
|
||||
* FN-6597 requires compact relative timestamps for task-chat agent groups and user messages without live polling.
|
||||
* Invalid or missing timestamps must return an empty string so UI callers can omit the label instead of rendering NaN or Invalid Date.
|
||||
*/
|
||||
export function formatRelativeTimeAgo(iso: string, now: number = Date.now()): string {
|
||||
if (!iso) return "";
|
||||
const bucket = getRelativeTimeBucket(iso, now);
|
||||
if (!bucket) {
|
||||
const timestampMs = Date.parse(iso);
|
||||
return iso && Number.isFinite(timestampMs) && now - timestampMs < 0 ? "just now" : "";
|
||||
}
|
||||
|
||||
const timestampMs = Date.parse(iso);
|
||||
if (!Number.isFinite(timestampMs)) return "";
|
||||
|
||||
const diffMs = Math.max(0, now - timestampMs);
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMinutes < 1) return "just now";
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return new Date(timestampMs).toLocaleDateString();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user