From 03cfc2dfc889f2cba72be33160a6b3d4567b1b35 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 24 Jul 2026 11:15:43 -0700 Subject: [PATCH] FN-8561: add lifecycle dates to task cards Show localized creation and completion dates directly on task cards. - Render compact, locale-aware lifecycle timestamps with accessible full-date titles. - Persist archive timestamps for completed archived-task fallbacks and add coverage. - Add translated labels, documentation, and a minor release changeset. Files changed: .changeset/fn-8561-task-card-dates.md | 7 +++ docs/dashboard-guide.md | 1 + .../__tests__/archive-entry-serialization.test.ts | 35 ++++++++++++++ packages/core/src/task-store/serialization.ts | 8 ++++ packages/core/src/types/task-core.ts | 5 ++ packages/dashboard/app/components/TaskCard.css | 27 +++++++++++ packages/dashboard/app/components/TaskCard.tsx | 55 ++++++++++++++++++++++ .../__tests__/TaskCard.host-inventory.test.tsx | 21 +++++++++ .../app/components/__tests__/TaskCard.test.tsx | 17 +++++++ .../dashboard/app/i18n/__tests__/format.test.ts | 19 +++++++- packages/dashboard/app/i18n/format.ts | 38 +++++++++++++++ packages/i18n/locales/en/app.json | 4 ++ packages/i18n/locales/es/app.json | 4 ++ packages/i18n/locales/fr/app.json | 4 ++ packages/i18n/locales/ko/app.json | 4 ++ packages/i18n/locales/zh-CN/app.json | 4 ++ packages/i18n/locales/zh-TW/app.json | 4 ++ 17 files changed, 256 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-8561 Fusion-Task-Lineage: 474b949b-d8e7-469a-b564-00acae66e58a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8561-task-card-dates.md | 7 +++ docs/dashboard-guide.md | 1 + .../archive-entry-serialization.test.ts | 35 ++++++++++++ packages/core/src/task-store/serialization.ts | 8 +++ packages/core/src/types/task-core.ts | 5 ++ .../dashboard/app/components/TaskCard.css | 27 +++++++++ .../dashboard/app/components/TaskCard.tsx | 55 +++++++++++++++++++ .../TaskCard.host-inventory.test.tsx | 21 +++++++ .../components/__tests__/TaskCard.test.tsx | 17 ++++++ .../app/i18n/__tests__/format.test.ts | 19 ++++++- packages/dashboard/app/i18n/format.ts | 38 +++++++++++++ packages/i18n/locales/en/app.json | 4 ++ packages/i18n/locales/es/app.json | 4 ++ packages/i18n/locales/fr/app.json | 4 ++ packages/i18n/locales/ko/app.json | 4 ++ packages/i18n/locales/zh-CN/app.json | 4 ++ packages/i18n/locales/zh-TW/app.json | 4 ++ 17 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-8561-task-card-dates.md create mode 100644 packages/core/src/__tests__/archive-entry-serialization.test.ts create mode 100644 packages/dashboard/app/components/__tests__/TaskCard.host-inventory.test.tsx diff --git a/.changeset/fn-8561-task-card-dates.md b/.changeset/fn-8561-task-card-dates.md new file mode 100644 index 0000000000..8900f359b7 --- /dev/null +++ b/.changeset/fn-8561-task-card-dates.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show creation and completion dates directly on task cards. +category: feature +dev: Archived cards retain their canonical archive timestamp for completion fallback. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 30a83dd9c7..bb6c0a7e84 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -244,6 +244,7 @@ Features: - GitLab tracking badges on task cards for linked GitLab project issues, group issues, and merge requests; stale GitLab metadata uses a warning-colored badge while GitHub badges remain unchanged. - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata +- Task cards show their creation time for today or local calendar day otherwise; done and archived cards also show their completion date. - Task card header meta badges group priority and fast mode in the header; priority badges include the shared urgency glyph/color language (low blue/info, high amber/warning, urgent red/error) while agent-created provenance renders in a dedicated bottom-left row ahead of workflow identity so the ID/status/actions header does not wrap on narrow cards. Agent labels prefer `sourceMetadata.agentName` over raw agent IDs. - **Settings → Appearance → Show cost badges on task cards** is default off. When enabled, board cards with recorded positive token usage show a compact derived-cost badge with the card's other footer/meta chips; unpriced models display `—`, and cards with no usage render no badge shell. diff --git a/packages/core/src/__tests__/archive-entry-serialization.test.ts b/packages/core/src/__tests__/archive-entry-serialization.test.ts new file mode 100644 index 0000000000..5f7a11d852 --- /dev/null +++ b/packages/core/src/__tests__/archive-entry-serialization.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { archiveEntryToTask } from "../task-store/serialization.js"; +import type { ArchivedTaskEntry } from "../types/archive-planning.js"; + +describe("archiveEntryToTask", () => { + const entry = { + id: "FN-8561", + lineageId: "lineage-8561", + description: "Archived task", + column: "archived", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-07-20T10:00:00.000Z", + updatedAt: "2026-07-23T10:00:00.000Z", + columnMovedAt: "2026-07-22T10:00:00.000Z", + executionCompletedAt: "2026-07-23T10:00:00.000Z", + archivedAt: "2026-07-24T10:00:00.000Z", + } as ArchivedTaskEntry; + + it.each([false, true])("preserves distinct lifecycle timestamps in slim=%s payloads", (slim) => { + const task = archiveEntryToTask(entry, slim); + + expect(task.column).toBe("archived"); + expect(task.columnMovedAt).toBe("2026-07-22T10:00:00.000Z"); + expect(task.executionCompletedAt).toBe("2026-07-23T10:00:00.000Z"); + expect(task.archivedAt).toBe("2026-07-24T10:00:00.000Z"); + }); + + it("keeps legacy and active-compatible payloads readable without archivedAt", () => { + const legacy = archiveEntryToTask({ ...entry, archivedAt: undefined } as unknown as ArchivedTaskEntry); + expect(legacy.archivedAt).toBeUndefined(); + }); +}); diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 12fb7ab228..54d4515f9f 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -373,6 +373,14 @@ export function archiveEntryToTask( planningStartedAt: entry.planningStartedAt, executionStartedAt: entry.executionStartedAt, executionCompletedAt: entry.executionCompletedAt, + /* + FNXC:ArchiveLifecycle 2026-07-24-11:02: + FN-8561 needs archived TaskCard completion fallback to use the immutable + archive transition, not the pre-archive columnMovedAt snapshot or updatedAt. + Preserve archivedAt on every slim and full archive read without changing + restore persistence semantics. + */ + archivedAt: entry.archivedAt, modelPresetId: entry.modelPresetId, modelProvider: entry.modelProvider, modelId: entry.modelId, diff --git a/packages/core/src/types/task-core.ts b/packages/core/src/types/task-core.ts index 030e86f1f9..4c3071430d 100644 --- a/packages/core/src/types/task-core.ts +++ b/packages/core/src/types/task-core.ts @@ -1149,6 +1149,11 @@ export interface Task { * Set once on first transition to `done`; may be cleared on reopen to * todo/triage when resume state is not preserved. */ executionCompletedAt?: string; + /** + * Canonical archive transition timestamp. Present only on task payloads + * hydrated from archived entries; active and legacy tasks may omit it. + */ + archivedAt?: string; deletedAt?: string; allowResurrection?: boolean; createdAt: string; diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 5f2fa6bb7a..fe0bf672d5 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -972,6 +972,27 @@ Task-card agent badges must retain their visible text label in narrow containers margin-top: var(--space-sm); } +/* +FNXC:TaskCardDates 2026-07-24-11:02: +Lifecycle dates share the card's compact metadata rhythm and wrap independently +on narrow/mobile cards, so added terminal metadata cannot force horizontal scroll. +*/ +.card-lifecycle-dates { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-top: var(--space-sm); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.2; +} + +.card-lifecycle-dates time { + min-width: 0; + overflow-wrap: anywhere; +} + .card-footer-row { display: flex; align-items: center; @@ -1941,6 +1962,12 @@ The three-dot menu is the sole card move/action entry point. Keep this shared bu padding: var(--space-xs) 0; } + .card-lifecycle-dates { + gap: var(--space-xs); + margin-top: var(--space-xs); + font-size: 0.625rem; + } + .card-footer-row { margin-top: var(--space-xs); gap: var(--space-xs); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 445ecdd417..7e2241bdb7 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -53,6 +53,7 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo import { useRetryWarning } from "../context/RetryWarningContext"; import { useCostBadge } from "../context/CostBadgeContext"; import { useColumnLabel } from "../i18n/labels"; +import { formatCompactLifecycleDate, useLocaleFormat } from "../i18n/format"; import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary"; import { WorkflowIcon } from "./WorkflowIcon"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu"; @@ -753,6 +754,12 @@ function areCommentsEqual(previous: Task["comments"], next: Task["comments"]): b // Keep this comparator aligned with the fields TaskCard renders directly and the // task metadata that influences child badge freshness/subscriptions. +function millisecondsUntilNextLocalMidnight(now: Date): number { + const next = new Date(now); + next.setHours(24, 0, 0, 25); + return Math.max(1, next.getTime() - now.getTime()); +} + function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): boolean { const previousTask = previous.task; const nextTask = next.task; @@ -820,6 +827,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.timedExecutionMs === nextTask.timedExecutionMs && previousTask.updatedAt === nextTask.updatedAt && previousTask.createdAt === nextTask.createdAt && + previousTask.executionCompletedAt === nextTask.executionCompletedAt && + previousTask.archivedAt === nextTask.archivedAt && previousTask.status === nextTask.status && previousTask.recentAgentActivityAt === nextTask.recentAgentActivityAt && previousTask.priority === nextTask.priority && @@ -963,6 +972,7 @@ function TaskCardComponent({ nearDuplicateCanonicalInactive, }: TaskCardProps) { const { t } = useTranslation("app"); + const { locale } = useLocaleFormat(); const columnLabel = useColumnLabel(); const [dragging, setDragging] = useState(false); const [fileDragOver, setFileDragOver] = useState(false); @@ -981,6 +991,27 @@ function TaskCardComponent({ const [isAddressingPrFeedback, setIsAddressingPrFeedback] = useState(false); const [isStarting, setIsStarting] = useState(false); const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now()); + const [lifecycleNowMs, setLifecycleNowMs] = useState(() => Date.now()); + + /* + FNXC:TaskCardDates 2026-07-24-11:02: + FN-8561 requires compact lifecycle labels to change at the viewer's local + midnight even when memoized task props are unchanged. One boundary timer per + mounted card avoids stale "today" time labels and is always cleaned up. + */ + useEffect(() => { + let timer: number | undefined; + const schedule = () => { + timer = window.setTimeout(() => { + setLifecycleNowMs(Date.now()); + schedule(); + }, millisecondsUntilNextLocalMidnight(new Date())); + }; + schedule(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; + }, []); const descTextareaRef = useRef(null); const touchOpenHandledRef = useRef(false); @@ -1622,6 +1653,16 @@ function TaskCardComponent({ }; }, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.firstExecutionAt, task.cumulativeActiveMs, task.cumulativePlanningMs, task.planningStartedAt, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]); + const lifecycleDates = useMemo(() => { + const created = formatCompactLifecycleDate(task.createdAt, locale, new Date(lifecycleNowMs)); + const completionSource = task.executionCompletedAt + ?? (task.column === "archived" ? task.archivedAt : undefined); + const completed = (task.column === "done" || task.column === "archived") + ? formatCompactLifecycleDate(completionSource, locale, new Date(lifecycleNowMs)) + : null; + return { created, completed }; + }, [task.createdAt, task.executionCompletedAt, task.archivedAt, task.column, locale, lifecycleNowMs]); + const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`); // Get fresh batch data if available @@ -3673,6 +3714,20 @@ function TaskCardComponent({ ); })()} + {(lifecycleDates.created || lifecycleDates.completed) && ( +
+ {lifecycleDates.created && ( + + )} + {lifecycleDates.completed && ( + + )} +
+ )} {(footerHasLeadingContent || (footerRightHasContent && !placeFooterRightInMeta)) && (
{filesChangedButton} diff --git a/packages/dashboard/app/components/__tests__/TaskCard.host-inventory.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.host-inventory.test.tsx new file mode 100644 index 0000000000..8cfdfd2987 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskCard.host-inventory.test.tsx @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const taskCardHosts = [ + "../Column.tsx", + "../DockTaskList.tsx", + "../useRightDockController.tsx", + "../WorktreeGroup.tsx", + "../dashboard/MainContent.tsx", +] as const; + +describe("TaskCard host inventory (FN-8561)", () => { + it("keeps every shared-card host delegated to the canonical TaskCard", () => { + for (const relativePath of taskCardHosts) { + const source = readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); + expect(source, relativePath).toMatch(/import\s+\{\s*TaskCard\s*\}\s+from/); + expect(source, relativePath).toMatch(/ { }); describe("TaskCard", () => { + it("renders creation on all cards and terminal completion from canonical lifecycle timestamps", () => { + const createdAt = new Date().toISOString(); + const archivedAt = "2026-07-20T09:00:00.000Z"; + const { rerender } = render( + , + ); + + expect(screen.getByTestId("card-lifecycle-dates")).toHaveTextContent("Created"); + expect(screen.queryByText(/Completed/)).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("card-lifecycle-dates")).toHaveTextContent("Completed"); + expect(screen.getByTestId("card-lifecycle-dates").querySelectorAll("time")).toHaveLength(2); + }); + it("renders GitLab tracking badges for linked and stale items without dropping GitHub badges", () => { const gitlabItem = { kind: "merge_request" as const, diff --git a/packages/dashboard/app/i18n/__tests__/format.test.ts b/packages/dashboard/app/i18n/__tests__/format.test.ts index 0c83966d93..018e7e2f4c 100644 --- a/packages/dashboard/app/i18n/__tests__/format.test.ts +++ b/packages/dashboard/app/i18n/__tests__/format.test.ts @@ -9,7 +9,7 @@ vi.mock("react-i18next", () => ({ }), })); -const { useLocaleFormat } = await import("../format"); +const { useLocaleFormat, formatCompactLifecycleDate } = await import("../format"); describe("useLocaleFormat", () => { it("formats numbers with the active locale's separators", () => { @@ -39,6 +39,23 @@ describe("useLocaleFormat", () => { expect(fr).toMatch(/janvier/); }); + it("formats lifecycle timestamps as time today and calendar day otherwise", () => { + const now = new Date(2026, 6, 24, 12, 0); + const today = formatCompactLifecycleDate(new Date(2026, 6, 24, 9, 5).toISOString(), "en-US", now); + const previousDay = formatCompactLifecycleDate(new Date(2026, 6, 23, 9, 5).toISOString(), "en-US", now); + const priorYear = formatCompactLifecycleDate(new Date(2025, 6, 24, 9, 5).toISOString(), "en-US", now); + + expect(today?.compact).toMatch(/9:05/); + expect(previousDay?.compact).toMatch(/Jul/); + expect(priorYear?.compact).toContain("2025"); + expect(today?.full).toBeTruthy(); + }); + + it("returns no lifecycle model for missing or invalid values", () => { + expect(formatCompactLifecycleDate(undefined, "en", new Date())).toBeNull(); + expect(formatCompactLifecycleDate("not-a-date", "en", new Date())).toBeNull(); + }); + it("exposes the resolved locale", () => { state.lng = "zh-CN"; expect(renderHook(() => useLocaleFormat()).result.current.locale).toBe("zh-CN"); diff --git a/packages/dashboard/app/i18n/format.ts b/packages/dashboard/app/i18n/format.ts index ddcecc417f..5a4884092a 100644 --- a/packages/dashboard/app/i18n/format.ts +++ b/packages/dashboard/app/i18n/format.ts @@ -1,6 +1,44 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +export interface CompactLifecycleDate { + compact: string; + full: string; + dateTime: string; +} + +/** + * Formats lifecycle timestamps against the viewer's local calendar, rather than + * UTC date boundaries. Invalid and missing legacy values intentionally produce + * no model so callers never render an Invalid Date shell. + */ +export function formatCompactLifecycleDate( + value: string | undefined, + locale: string, + now: Date = new Date(), +): CompactLifecycleDate | null { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime()) || Number.isNaN(now.getTime())) return null; + + const sameDay = date.getFullYear() === now.getFullYear() + && date.getMonth() === now.getMonth() + && date.getDate() === now.getDate(); + const compact = sameDay + ? new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date) + : new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + ...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}), + }).format(date); + + return { + compact, + full: new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date), + dateTime: date.toISOString(), + }; +} + /** * Locale-aware date/number formatting bound to the active i18n locale. * diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 26e3d8ccf6..b6fbc46bc0 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8325,6 +8325,10 @@ "closeIssue": "Close Issue", "collapse": "Collapse", "createdByAgent": "Created by agent", + "createdAt": "Created {{date}}", + "createdAtTitle": "Created {{date}}", + "completedAt": "Completed {{date}}", + "completedAtTitle": "Completed {{date}}", "createdByAgentNamed": "Created by agent: {{name}}", "createdPr": "Created PR #{{number}}", "createFailed": "Failed to create task", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index d9f2296e43..09b665dd2f 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -8306,6 +8306,10 @@ "closeIssue": "Cerrar issue", "collapse": "Contraer", "createdByAgent": "Creado por un agente", + "createdAt": "Creada {{date}}", + "createdAtTitle": "Creada {{date}}", + "completedAt": "Completada {{date}}", + "completedAtTitle": "Completada {{date}}", "createdByAgentNamed": "Creado por el agente: {{name}}", "createdPr": "PR #{{number}} creada", "createFailed": "Error al crear la tarea", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index e6981cf4e4..97061313f4 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -8306,6 +8306,10 @@ "closeIssue": "Fermer l'issue", "collapse": "Réduire", "createdByAgent": "Créé par un agent", + "createdAt": "Créée {{date}}", + "createdAtTitle": "Créée {{date}}", + "completedAt": "Terminée {{date}}", + "completedAtTitle": "Terminée {{date}}", "createdByAgentNamed": "Créé par l'agent : {{name}}", "createdPr": "PR #{{number}} créée", "createFailed": "Impossible de créer la tâche", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index ee3b2d5130..21cc87db2c 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -8306,6 +8306,10 @@ "closeIssue": "이슈 닫기", "collapse": "접기", "createdByAgent": "에이전트가 생성함", + "createdAt": "생성됨 {{date}}", + "createdAtTitle": "생성됨 {{date}}", + "completedAt": "완료됨 {{date}}", + "completedAtTitle": "완료됨 {{date}}", "createdByAgentNamed": "에이전트가 생성함: {{name}}", "createdPr": "PR #{{number}} 생성됨", "createFailed": "작업 생성에 실패했습니다", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 6576dfc6a3..fb2b323d4d 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -8306,6 +8306,10 @@ "closeIssue": "关闭 Issue", "collapse": "折叠", "createdByAgent": "由智能体创建", + "createdAt": "创建于 {{date}}", + "createdAtTitle": "创建于 {{date}}", + "completedAt": "完成于 {{date}}", + "completedAtTitle": "完成于 {{date}}", "createdByAgentNamed": "由智能体创建:{{name}}", "createdPr": "已创建 PR #{{number}}", "createFailed": "创建任务失败", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index cd591582b9..d162ad967a 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -8306,6 +8306,10 @@ "closeIssue": "關閉 Issue", "collapse": "收合", "createdByAgent": "由代理程式建立", + "createdAt": "建立於 {{date}}", + "createdAtTitle": "建立於 {{date}}", + "completedAt": "完成於 {{date}}", + "completedAtTitle": "完成於 {{date}}", "createdByAgentNamed": "由代理程式建立:{{name}}", "createdPr": "已建立 PR #{{number}}", "createFailed": "建立任務失敗",