100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
|
|
// 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;
|
|||
|
|
}
|