feat(FN-2512): add elapsed timer chip to task cards

- Derive task elapsed time from columnMovedAt with updatedAt/createdAt fallbacks and guard against invalid or future timestamps
- Render a clock-based timer chip on in-progress and done cards with accessible labeling and tooltip metadata
- Add TaskCard styles for the timer row/chip using design tokens, including mobile-size adjustments
- Expand TaskCard tests to cover visibility by column, invalid timestamp suppression, boundary label formatting, and 30s live refresh cadence
This commit is contained in:
Fusion
2026-04-25 12:46:06 -07:00
committed by gsxdsm
parent ff6a68bac4
commit f560af593a
9 changed files with 454 additions and 16 deletions

View File

@@ -91,6 +91,40 @@ const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
archived: "var(--text-muted)",
};
const TIME_INDICATOR_COLUMNS = new Set<Column>(["in-progress", "done"]);
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
function parseTimestampToMs(value?: string): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function getTimeIndicatorStartMs(task: Task): number | null {
const timestamp = task.columnMovedAt ?? task.updatedAt ?? task.createdAt;
const parsed = parseTimestampToMs(timestamp);
if (parsed == null) return null;
const now = Date.now();
if (parsed > now) return null;
return parsed;
}
function formatElapsedDuration(elapsedMs: number): string {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "";
const elapsedMinutes = Math.floor(elapsedMs / 60_000);
if (elapsedMinutes < 1) return "<1m";
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h`;
const elapsedDays = Math.floor(elapsedHours / 24);
return `${elapsedDays}d`;
}
interface TaskCardProps {
task: Task;
projectId?: string;
@@ -318,6 +352,7 @@ function TaskCardComponent({
const [missionTitle, setMissionTitle] = useState<string | null>(null);
const [agentName, setAgentName] = useState<string | null>(null);
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
@@ -564,6 +599,46 @@ function TaskCardComponent({
const showProgressSection =
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
useEffect(() => {
if (task.column !== "in-progress") {
return;
}
const startMs = getTimeIndicatorStartMs(task);
if (startMs == null) {
return;
}
setTimeIndicatorNowMs(Date.now());
const interval = window.setInterval(() => {
setTimeIndicatorNowMs(Date.now());
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
return null;
}
const startMs = getTimeIndicatorStartMs(task);
if (startMs == null) {
return null;
}
const referenceNowMs = task.column === "in-progress" ? timeIndicatorNowMs : Date.now();
const elapsedLabel = formatElapsedDuration(referenceNowMs - startMs);
if (!elapsedLabel) {
return null;
}
return {
label: elapsedLabel,
title: `Since ${new Date(startMs).toLocaleString()}`,
};
}, [task.column, task.columnMovedAt, task.updatedAt, task.createdAt, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {
unsubscribeFromBadge(task.id);
@@ -1162,6 +1237,18 @@ function TaskCardComponent({
}
return null;
})()}
{timeIndicator && (
<div className="card-time-row">
<span
className="card-time-indicator"
title={timeIndicator.title}
aria-label={`Elapsed time ${timeIndicator.label}. ${timeIndicator.title}`}
>
<Clock size={12} />
<span>{timeIndicator.label}</span>
</span>
</div>
)}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
<div className="card-meta">
{task.dependencies && task.dependencies.length > 0 && (