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:
gsxdsm
2026-06-17 17:57:19 -07:00
parent 5b9ff047d3
commit ca14a12b94
8 changed files with 236 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" }),

View File

@@ -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" }),

View File

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

View File

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