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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-24 11:15:43 -07:00
parent ab41554980
commit 03cfc2dfc8
17 changed files with 256 additions and 1 deletions

View File

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

View File

@@ -244,6 +244,7 @@ Features:
<!-- FNXC:TaskActivity 2026-07-28-12:00: FN-8300 requires visual card activity to agree with fresh planner logs during status-null planning transitions; Board and List reuse their existing active affordances. -->
- 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.
<!-- FNXC:TaskCardCostBadge 2026-07-11-12:25: The card spend badge is opt-in because card footers are dense. It must remain guess-free (unpriced `—`, no fabricated `$0`) and absent for tasks without positive token usage. -->

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<HTMLTextAreaElement>(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) && (
<div className="card-lifecycle-dates" data-testid="card-lifecycle-dates">
{lifecycleDates.created && (
<time dateTime={lifecycleDates.created.dateTime} title={t("tasks.createdAtTitle", "Created {{date}}", { date: lifecycleDates.created.full })}>
{t("tasks.createdAt", "Created {{date}}", { date: lifecycleDates.created.compact })}
</time>
)}
{lifecycleDates.completed && (
<time dateTime={lifecycleDates.completed.dateTime} title={t("tasks.completedAtTitle", "Completed {{date}}", { date: lifecycleDates.completed.full })}>
{t("tasks.completedAt", "Completed {{date}}", { date: lifecycleDates.completed.compact })}
</time>
)}
</div>
)}
{(footerHasLeadingContent || (footerRightHasContent && !placeFooterRightInMeta)) && (
<div className={`card-footer-row${chipFarRight ? " card-footer-row--chip-far-right" : ""}`}>
{filesChangedButton}

View File

@@ -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(/<TaskCard\b/);
}
});
});

View File

@@ -243,6 +243,23 @@ afterEach(() => {
});
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(
<TaskCard task={makeTask({ createdAt })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByTestId("card-lifecycle-dates")).toHaveTextContent("Created");
expect(screen.queryByText(/Completed/)).not.toBeInTheDocument();
rerender(
<TaskCard task={makeTask({ column: "archived", createdAt, archivedAt, columnMovedAt: "2026-07-19T09:00:00.000Z" })} onOpenDetail={noop} addToast={noop} />,
);
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,

View File

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

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

@@ -8306,6 +8306,10 @@
"closeIssue": "이슈 닫기",
"collapse": "접기",
"createdByAgent": "에이전트가 생성함",
"createdAt": "생성됨 {{date}}",
"createdAtTitle": "생성됨 {{date}}",
"completedAt": "완료됨 {{date}}",
"completedAtTitle": "완료됨 {{date}}",
"createdByAgentNamed": "에이전트가 생성함: {{name}}",
"createdPr": "PR #{{number}} 생성됨",
"createFailed": "작업 생성에 실패했습니다",

View File

@@ -8306,6 +8306,10 @@
"closeIssue": "关闭 Issue",
"collapse": "折叠",
"createdByAgent": "由智能体创建",
"createdAt": "创建于 {{date}}",
"createdAtTitle": "创建于 {{date}}",
"completedAt": "完成于 {{date}}",
"completedAtTitle": "完成于 {{date}}",
"createdByAgentNamed": "由智能体创建:{{name}}",
"createdPr": "已创建 PR #{{number}}",
"createFailed": "创建任务失败",

View File

@@ -8306,6 +8306,10 @@
"closeIssue": "關閉 Issue",
"collapse": "收合",
"createdByAgent": "由代理程式建立",
"createdAt": "建立於 {{date}}",
"createdAtTitle": "建立於 {{date}}",
"completedAt": "完成於 {{date}}",
"completedAtTitle": "完成於 {{date}}",
"createdByAgentNamed": "由代理程式建立:{{name}}",
"createdPr": "已建立 PR #{{number}}",
"createFailed": "建立任務失敗",