feat: webhook to Clawdbot when task activated + session auth for all mutations
This commit is contained in:
@@ -5,6 +5,37 @@ import { eq, asc, desc, sql, inArray } from "drizzle-orm";
|
|||||||
import { auth } from "../lib/auth";
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||||
|
const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "http://127.0.0.1:18789/hooks/agent";
|
||||||
|
const CLAWDBOT_HOOK_TOKEN = process.env.CLAWDBOT_HOOK_TOKEN || "";
|
||||||
|
|
||||||
|
// Fire webhook to Clawdbot when a task is activated
|
||||||
|
async function notifyTaskActivated(task: { id: string; title: string; description: string | null; source: string; priority: string }) {
|
||||||
|
if (!CLAWDBOT_HOOK_TOKEN) {
|
||||||
|
console.warn("CLAWDBOT_HOOK_TOKEN not set — skipping webhook");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const message = `🔨 Task activated in Hammer Queue:\n\nTitle: ${task.title}\nPriority: ${task.priority}\nSource: ${task.source}\nID: ${task.id}\n${task.description ? `\nDescription: ${task.description}` : ""}\n\nStart working on this task. Post progress notes to the queue API as you work:\ncurl -s -H "Authorization: Bearer $HAMMER_QUEUE_API_KEY" -H "Content-Type: application/json" -X POST "https://queue.donovankelly.xyz/api/tasks/${task.id}/notes" -d '{"note":"your update here"}'`;
|
||||||
|
|
||||||
|
await fetch(CLAWDBOT_HOOK_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${CLAWDBOT_HOOK_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
name: "HammerQueue",
|
||||||
|
sessionKey: `hook:queue:${task.id}`,
|
||||||
|
deliver: true,
|
||||||
|
channel: "telegram",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`Webhook fired for task ${task.id}: ${task.title}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fire webhook:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Status sort order: active first, then queued, blocked, completed, cancelled
|
// Status sort order: active first, then queued, blocked, completed, cancelled
|
||||||
const statusOrder = sql`CASE
|
const statusOrder = sql`CASE
|
||||||
@@ -62,11 +93,11 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
return allTasks;
|
return allTasks;
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST create task - requires auth
|
// POST create task - requires session or bearer auth
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
async ({ body, headers }) => {
|
async ({ body, request, headers }) => {
|
||||||
requireBearerAuth(headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
// Get max position for queued tasks
|
// Get max position for queued tasks
|
||||||
const maxPos = await db
|
const maxPos = await db
|
||||||
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
|
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
|
||||||
@@ -120,11 +151,11 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// PATCH update task - requires auth
|
// PATCH update task - requires session or bearer auth
|
||||||
.patch(
|
.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, body, headers }) => {
|
async ({ params, body, request, headers }) => {
|
||||||
requireBearerAuth(headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||||
if (body.title !== undefined) updates.title = body.title;
|
if (body.title !== undefined) updates.title = body.title;
|
||||||
if (body.description !== undefined) updates.description = body.description;
|
if (body.description !== undefined) updates.description = body.description;
|
||||||
@@ -151,6 +182,12 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
.where(eq(tasks.id, params.id))
|
.where(eq(tasks.id, params.id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!updated.length) throw new Error("Task not found");
|
if (!updated.length) throw new Error("Task not found");
|
||||||
|
|
||||||
|
// Fire webhook if task was just activated
|
||||||
|
if (body.status === "active") {
|
||||||
|
notifyTaskActivated(updated[0]);
|
||||||
|
}
|
||||||
|
|
||||||
return updated[0];
|
return updated[0];
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,8 +206,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
// POST add progress note - requires auth
|
// POST add progress note - requires auth
|
||||||
.post(
|
.post(
|
||||||
"/:id/notes",
|
"/:id/notes",
|
||||||
async ({ params, body, headers }) => {
|
async ({ params, body, request, headers }) => {
|
||||||
requireBearerAuth(headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
@@ -200,8 +237,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
// PATCH reorder tasks - requires auth
|
// PATCH reorder tasks - requires auth
|
||||||
.patch(
|
.patch(
|
||||||
"/reorder",
|
"/reorder",
|
||||||
async ({ body, headers }) => {
|
async ({ body, request, headers }) => {
|
||||||
requireBearerAuth(headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
// body.ids is an ordered array of task IDs
|
// body.ids is an ordered array of task IDs
|
||||||
const updates = body.ids.map((id: string, index: number) =>
|
const updates = body.ids.map((id: string, index: number) =>
|
||||||
db
|
db
|
||||||
@@ -220,8 +257,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
// DELETE task - requires auth
|
// DELETE task - requires auth
|
||||||
.delete(
|
.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, headers }) => {
|
async ({ params, request, headers }) => {
|
||||||
requireBearerAuth(headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
const deleted = await db
|
const deleted = await db
|
||||||
.delete(tasks)
|
.delete(tasks)
|
||||||
.where(eq(tasks.id, params.id))
|
.where(eq(tasks.id, params.id))
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ services:
|
|||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
BETTER_AUTH_URL: https://queue.donovankelly.xyz
|
BETTER_AUTH_URL: https://queue.donovankelly.xyz
|
||||||
COOKIE_DOMAIN: .donovankelly.xyz
|
COOKIE_DOMAIN: .donovankelly.xyz
|
||||||
|
CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL}
|
||||||
|
CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN}
|
||||||
PORT: "3100"
|
PORT: "3100"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -19,19 +19,15 @@ const sourceColors: Record<string, string> = {
|
|||||||
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
||||||
active: [
|
active: [
|
||||||
{ label: "⏸ Pause", next: "queued" },
|
{ label: "⏸ Pause", next: "queued" },
|
||||||
{ label: "🚫 Block", next: "blocked" },
|
|
||||||
{ label: "✅ Complete", next: "completed" },
|
{ label: "✅ Complete", next: "completed" },
|
||||||
{ label: "❌ Cancel", next: "cancelled" },
|
|
||||||
],
|
],
|
||||||
queued: [
|
queued: [
|
||||||
{ label: "▶ Activate", next: "active" },
|
{ label: "▶ Activate", next: "active" },
|
||||||
{ label: "🚫 Block", next: "blocked" },
|
|
||||||
{ label: "❌ Cancel", next: "cancelled" },
|
{ label: "❌ Cancel", next: "cancelled" },
|
||||||
],
|
],
|
||||||
blocked: [
|
blocked: [
|
||||||
{ label: "▶ Activate", next: "active" },
|
{ label: "▶ Activate", next: "active" },
|
||||||
{ label: "📋 Queue", next: "queued" },
|
{ label: "📋 Queue", next: "queued" },
|
||||||
{ label: "❌ Cancel", next: "cancelled" },
|
|
||||||
],
|
],
|
||||||
completed: [{ label: "🔄 Requeue", next: "queued" }],
|
completed: [{ label: "🔄 Requeue", next: "queued" }],
|
||||||
cancelled: [{ label: "🔄 Requeue", next: "queued" }],
|
cancelled: [{ label: "🔄 Requeue", next: "queued" }],
|
||||||
@@ -56,6 +52,7 @@ interface TaskCardProps {
|
|||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
@@ -66,77 +63,64 @@ export function TaskCard({
|
|||||||
isFirst,
|
isFirst,
|
||||||
isLast,
|
isLast,
|
||||||
isActive,
|
isActive,
|
||||||
|
onClick,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const actions = statusActions[task.status] || [];
|
const actions = statusActions[task.status] || [];
|
||||||
|
const noteCount = task.progressNotes?.length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border p-4 transition-all ${
|
onClick={onClick}
|
||||||
|
className={`rounded-xl border p-4 transition-all cursor-pointer group ${
|
||||||
isActive
|
isActive
|
||||||
? "border-amber-400 bg-amber-50 shadow-lg shadow-amber-100"
|
? "border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 shadow-lg shadow-amber-100/50 hover:shadow-xl hover:shadow-amber-200/50"
|
||||||
: "border-gray-200 bg-white shadow-sm hover:shadow-md"
|
: "border-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
<div className="flex items-center gap-2 flex-wrap mb-1.5">
|
||||||
{isActive && (
|
{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">
|
<span className="relative flex h-2.5 w-2.5 mr-0.5">
|
||||||
⚡ ACTIVE
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-semibold text-gray-900 truncate">{task.title}</h3>
|
<h3 className={`font-semibold truncate ${isActive ? "text-amber-900" : "text-gray-900"}`}>
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<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]}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
||||||
{task.priority}
|
{task.priority}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source]}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
|
||||||
{task.source}
|
{task.source}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
created {timeAgo(task.createdAt)}
|
{timeAgo(task.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
{task.updatedAt !== task.createdAt && (
|
{noteCount > 0 && (
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400 flex items-center gap-0.5">
|
||||||
· updated {timeAgo(task.updatedAt)}
|
💬 {noteCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-sm text-gray-600 mb-2 line-clamp-2">{task.description}</p>
|
<p className="text-sm text-gray-500 line-clamp-1">{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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Reorder buttons for queued tasks */}
|
{/* Reorder buttons for queued tasks */}
|
||||||
{task.status === "queued" && (
|
{task.status === "queued" && (
|
||||||
<div className="flex gap-1 mb-1">
|
<div className="flex gap-1 mr-1">
|
||||||
<button
|
<button
|
||||||
onClick={onMoveUp}
|
onClick={onMoveUp}
|
||||||
disabled={isFirst}
|
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"
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
↑
|
↑
|
||||||
@@ -144,7 +128,7 @@ export function TaskCard({
|
|||||||
<button
|
<button
|
||||||
onClick={onMoveDown}
|
onClick={onMoveDown}
|
||||||
disabled={isLast}
|
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"
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
@@ -152,16 +136,23 @@ export function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status actions */}
|
{/* Quick status actions - show fewer on card, full set in detail */}
|
||||||
{actions.map((action) => (
|
{actions.slice(0, 2).map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.next}
|
key={action.next}
|
||||||
onClick={() => onStatusChange(task.id, 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"
|
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-gray-600 hover:text-gray-800 transition font-medium"
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Expand indicator */}
|
||||||
|
<div className="ml-1 text-gray-300 group-hover:text-gray-500 transition">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
288
frontend/src/components/TaskDetailPanel.tsx
Normal file
288
frontend/src/components/TaskDetailPanel.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
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 priorityIcons: Record<TaskPriority, string> = {
|
||||||
|
critical: "🔴",
|
||||||
|
high: "🟠",
|
||||||
|
medium: "🔵",
|
||||||
|
low: "⚪",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<TaskStatus, string> = {
|
||||||
|
active: "bg-amber-100 text-amber-800 border-amber-300",
|
||||||
|
queued: "bg-blue-100 text-blue-800 border-blue-300",
|
||||||
|
blocked: "bg-red-100 text-red-800 border-red-300",
|
||||||
|
completed: "bg-green-100 text-green-800 border-green-300",
|
||||||
|
cancelled: "bg-gray-100 text-gray-600 border-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcons: Record<TaskStatus, string> = {
|
||||||
|
active: "⚡",
|
||||||
|
queued: "📋",
|
||||||
|
blocked: "🚫",
|
||||||
|
completed: "✅",
|
||||||
|
cancelled: "❌",
|
||||||
|
};
|
||||||
|
|
||||||
|
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; color: string }[]> = {
|
||||||
|
active: [
|
||||||
|
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
||||||
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
||||||
|
{ label: "✅ Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100" },
|
||||||
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
||||||
|
],
|
||||||
|
queued: [
|
||||||
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
||||||
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
||||||
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
||||||
|
],
|
||||||
|
blocked: [
|
||||||
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
||||||
|
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
||||||
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
||||||
|
],
|
||||||
|
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
||||||
|
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ElapsedTimer({ since }: { since: string }) {
|
||||||
|
const [elapsed, setElapsed] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(since).getTime()) / 1000);
|
||||||
|
const h = Math.floor(diff / 3600);
|
||||||
|
const m = Math.floor((diff % 3600) / 60);
|
||||||
|
const s = diff % 60;
|
||||||
|
if (h > 0) {
|
||||||
|
setElapsed(`${h}h ${m}m ${s}s`);
|
||||||
|
} else if (m > 0) {
|
||||||
|
setElapsed(`${m}m ${s}s`);
|
||||||
|
} else {
|
||||||
|
setElapsed(`${s}s`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const interval = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [since]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-amber-700 font-semibold">{elapsed}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailPanelProps {
|
||||||
|
task: Task;
|
||||||
|
onClose: () => void;
|
||||||
|
onStatusChange: (id: string, status: TaskStatus) => void;
|
||||||
|
hasToken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: TaskDetailPanelProps) {
|
||||||
|
const actions = statusActions[task.status] || [];
|
||||||
|
const isActive = task.status === "active";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="fixed inset-y-0 right-0 w-full max-w-lg bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`px-6 py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{isActive && (
|
||||||
|
<span className="relative flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold border ${statusColors[task.status]}`}>
|
||||||
|
{statusIcons[task.status]} {task.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
||||||
|
{priorityIcons[task.priority]} {task.priority}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
|
||||||
|
{task.source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 leading-snug">{task.title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 -mr-2 -mt-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">{task.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Info */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Timeline</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Created</span>
|
||||||
|
<span className="text-gray-700 font-medium">{formatDate(task.createdAt)} <span className="text-gray-400 text-xs">({timeAgo(task.createdAt)})</span></span>
|
||||||
|
</div>
|
||||||
|
{task.updatedAt !== task.createdAt && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Updated</span>
|
||||||
|
<span className="text-gray-700 font-medium">{formatDate(task.updatedAt)} <span className="text-gray-400 text-xs">({timeAgo(task.updatedAt)})</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.completedAt && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Completed</span>
|
||||||
|
<span className="text-gray-700 font-medium">{formatDate(task.completedAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex items-center justify-between text-sm mt-1 pt-2 border-t border-amber-100">
|
||||||
|
<span className="text-amber-600 font-medium">⏱ Running for</span>
|
||||||
|
<ElapsedTimer since={task.updatedAt} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Notes */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
Progress Notes {task.progressNotes?.length > 0 && (
|
||||||
|
<span className="text-gray-300 ml-1">({task.progressNotes.length})</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-400 italic py-4 text-center border-2 border-dashed border-gray-100 rounded-lg">
|
||||||
|
No progress notes yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{task.progressNotes
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((note, i) => (
|
||||||
|
<div key={i} className="relative pl-6 pb-4 last:pb-0 group">
|
||||||
|
{/* Timeline line */}
|
||||||
|
{i < task.progressNotes.length - 1 && (
|
||||||
|
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 group-last:hidden" />
|
||||||
|
)}
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className={`absolute left-0 top-1 w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center ${
|
||||||
|
i === 0 && isActive
|
||||||
|
? "border-amber-400 bg-amber-50"
|
||||||
|
: "border-gray-300 bg-white"
|
||||||
|
}`}>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
i === 0 && isActive ? "bg-amber-500" : "bg-gray-300"
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{note.note}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">{formatTimestamp(note.timestamp)} · {timeAgo(note.timestamp)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Footer */}
|
||||||
|
{hasToken && actions.length > 0 && (
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.next}
|
||||||
|
onClick={() => {
|
||||||
|
onStatusChange(task.id, action.next);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`text-sm px-4 py-2 rounded-lg border font-medium transition ${action.color}`}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task ID */}
|
||||||
|
<div className="px-6 py-2 border-t border-gray-100 bg-gray-50">
|
||||||
|
<p className="text-xs text-gray-300 font-mono truncate">ID: {task.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user