Workflow-App/Workflow/app/tasks/TaskCard.tsx

100 lines
3.2 KiB
TypeScript
Raw Normal View History

// app/tasks/TaskCard.tsx
// Compact card for a single task. Used inside the Kanban columns.
"use client";
import type { Task } from "./api";
import {
TASK_TYPE_META,
PRIORITY_COLORS,
PRIORITY_LABEL,
DONE_COLLAPSE_DAYS,
} from "./columns";
interface TaskCardProps {
task: Task;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
isDragging?: boolean;
}
export default function TaskCard({
task,
onClick,
onDragStart,
onDragEnd,
isDragging,
}: TaskCardProps) {
const meta = TASK_TYPE_META[task.task_type];
const priorityClass = PRIORITY_COLORS[task.priority];
const isDone = task.status === "done";
const isOld = isDone && isOlderThanDays(task.completed_at, DONE_COLLAPSE_DAYS);
return (
<div
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onClick}
data-od-id={`task-card-${task.id}`}
className={`group rounded-md border border-border bg-surface p-3 cursor-grab active:cursor-grabbing transition-all ${
isDragging ? "opacity-50 scale-95" : "hover:border-accent hover:shadow-sm"
} ${isOld ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<h4 className="text-sm font-semibold text-fg leading-tight line-clamp-2">{task.title}</h4>
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.tone}`}>
{meta.icon} {meta.label}
</span>
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${priorityClass}`}>
{PRIORITY_LABEL[task.priority]}
</span>
{task.retry_count > 0 ? (
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-orange-50 text-orange-800 border-orange-300">
retry ×{task.retry_count}
</span>
) : null}
</div>
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{relativeTime(task.created)}</span>
{task.duration_ms != null ? (
<span className="font-mono">{(task.duration_ms / 1000).toFixed(1)}s</span>
) : null}
</div>
{task.error ? (
<p className="mt-2 text-[11px] text-red-700 line-clamp-2" title={task.error}>
❌ {task.error}
</p>
) : null}
</div>
);
}
function relativeTime(iso: string | null): string {
if (!iso) return "—";
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z").getTime();
if (isNaN(t)) return "—";
const diff = Date.now() - t;
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
function isOlderThanDays(iso: string | null, days: number): boolean {
if (!iso) return false;
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z").getTime();
if (isNaN(t)) return false;
return Date.now() - t > days * 24 * 60 * 60 * 1000;
}