- Backend: Elysia + Bun + Drizzle ORM + PostgreSQL - Frontend: React + Vite + TypeScript + Tailwind CSS - Task CRUD API with bearer token auth for writes - Public read-only dashboard with auto-refresh - Task states: active, queued, blocked, completed, cancelled - Reorder support for queue management - Progress notes per task - Docker Compose for local dev and Dokploy deployment
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
import type { Task, TaskStatus, TaskPriority } from "../lib/types";
|
|
|
|
const priorityColors: Record<TaskPriority, string> = {
|
|
critical: "bg-red-500 text-white",
|
|
high: "bg-orange-500 text-white",
|
|
medium: "bg-blue-500 text-white",
|
|
low: "bg-gray-400 text-white",
|
|
};
|
|
|
|
const sourceColors: Record<string, string> = {
|
|
donovan: "bg-purple-100 text-purple-800",
|
|
david: "bg-green-100 text-green-800",
|
|
hammer: "bg-yellow-100 text-yellow-800",
|
|
heartbeat: "bg-pink-100 text-pink-800",
|
|
cron: "bg-indigo-100 text-indigo-800",
|
|
other: "bg-gray-100 text-gray-800",
|
|
};
|
|
|
|
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
|
active: [
|
|
{ label: "⏸ Pause", next: "queued" },
|
|
{ label: "🚫 Block", next: "blocked" },
|
|
{ label: "✅ Complete", next: "completed" },
|
|
{ label: "❌ Cancel", next: "cancelled" },
|
|
],
|
|
queued: [
|
|
{ label: "▶ Activate", next: "active" },
|
|
{ label: "🚫 Block", next: "blocked" },
|
|
{ label: "❌ Cancel", next: "cancelled" },
|
|
],
|
|
blocked: [
|
|
{ label: "▶ Activate", next: "active" },
|
|
{ label: "📋 Queue", next: "queued" },
|
|
{ label: "❌ Cancel", next: "cancelled" },
|
|
],
|
|
completed: [{ label: "🔄 Requeue", next: "queued" }],
|
|
cancelled: [{ label: "🔄 Requeue", next: "queued" }],
|
|
};
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
interface TaskCardProps {
|
|
task: Task;
|
|
onStatusChange: (id: string, status: TaskStatus) => void;
|
|
onMoveUp?: () => void;
|
|
onMoveDown?: () => void;
|
|
isFirst?: boolean;
|
|
isLast?: boolean;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export function TaskCard({
|
|
task,
|
|
onStatusChange,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
isFirst,
|
|
isLast,
|
|
isActive,
|
|
}: TaskCardProps) {
|
|
const actions = statusActions[task.status] || [];
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-lg border p-4 transition-all ${
|
|
isActive
|
|
? "border-amber-400 bg-amber-50 shadow-lg shadow-amber-100"
|
|
: "border-gray-200 bg-white shadow-sm hover:shadow-md"
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
|
{isActive && (
|
|
<span className="inline-flex items-center text-xs font-bold text-amber-700 bg-amber-200 px-2 py-0.5 rounded-full animate-pulse">
|
|
⚡ ACTIVE
|
|
</span>
|
|
)}
|
|
<h3 className="font-semibold text-gray-900 truncate">{task.title}</h3>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
|
{task.priority}
|
|
</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source]}`}>
|
|
{task.source}
|
|
</span>
|
|
<span className="text-xs text-gray-400">
|
|
created {timeAgo(task.createdAt)}
|
|
</span>
|
|
{task.updatedAt !== task.createdAt && (
|
|
<span className="text-xs text-gray-400">
|
|
· updated {timeAgo(task.updatedAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{task.description && (
|
|
<p className="text-sm text-gray-600 mb-2 line-clamp-2">{task.description}</p>
|
|
)}
|
|
|
|
{task.progressNotes && task.progressNotes.length > 0 && (
|
|
<details className="mt-2">
|
|
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
|
{task.progressNotes.length} progress note{task.progressNotes.length !== 1 ? "s" : ""}
|
|
</summary>
|
|
<div className="mt-1 space-y-1 pl-2 border-l-2 border-gray-200">
|
|
{task.progressNotes
|
|
.slice()
|
|
.reverse()
|
|
.map((note, i) => (
|
|
<div key={i} className="text-xs text-gray-600">
|
|
<span className="text-gray-400">{timeAgo(note.timestamp)}</span>{" "}
|
|
— {note.note}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 shrink-0">
|
|
{/* Reorder buttons for queued tasks */}
|
|
{task.status === "queued" && (
|
|
<div className="flex gap-1 mb-1">
|
|
<button
|
|
onClick={onMoveUp}
|
|
disabled={isFirst}
|
|
className="text-xs px-1.5 py-0.5 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
title="Move up"
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
onClick={onMoveDown}
|
|
disabled={isLast}
|
|
className="text-xs px-1.5 py-0.5 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
title="Move down"
|
|
>
|
|
↓
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status actions */}
|
|
{actions.map((action) => (
|
|
<button
|
|
key={action.next}
|
|
onClick={() => onStatusChange(task.id, action.next)}
|
|
className="text-xs px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-left"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|