diff --git a/packages/dashboard/app/components/ActivityFeed.tsx b/packages/dashboard/app/components/ActivityFeed.tsx index d32c90ece6..21f71e920d 100644 --- a/packages/dashboard/app/components/ActivityFeed.tsx +++ b/packages/dashboard/app/components/ActivityFeed.tsx @@ -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): 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 = { diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 32ba3b1eac..793a505990 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -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): 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 { diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index f3f5758cfc..f7c97d5342 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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(); + } } diff --git a/packages/dashboard/app/components/__tests__/ActivityFeed.test.tsx b/packages/dashboard/app/components/__tests__/ActivityFeed.test.tsx index 3d09f7b6d2..3f47ca80ec 100644 --- a/packages/dashboard/app/components/__tests__/ActivityFeed.test.tsx +++ b/packages/dashboard/app/components/__tests__/ActivityFeed.test.tsx @@ -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(); + + expect(screen.getByText("Just now")).toBeDefined(); + }); + it("renders different event types with correct labels", () => { const entries: ActivityFeedEntry[] = [ makeEntry({ id: "1", type: "task:created" }), diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx index 64245f4620..2e4b7ec360 100644 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx @@ -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(); + + 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" }), diff --git a/packages/dashboard/app/utils/__tests__/relativeTimeAgo.test.ts b/packages/dashboard/app/utils/__tests__/relativeTimeAgo.test.ts index 6cd65341f8..4575f8068c 100644 --- a/packages/dashboard/app/utils/__tests__/relativeTimeAgo.test.ts +++ b/packages/dashboard/app/utils/__tests__/relativeTimeAgo.test.ts @@ -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); + }); }); diff --git a/packages/dashboard/app/utils/relativeTimeAgo.ts b/packages/dashboard/app/utils/relativeTimeAgo.ts index 32ed1c4957..78006b6aa0 100644 --- a/packages/dashboard/app/utils/relativeTimeAgo.ts +++ b/packages/dashboard/app/utils/relativeTimeAgo.ts @@ -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(); + } }