Files
hammer-queue/frontend/src/pages/TaskPage.tsx
Hammer b7ff8437e4
All checks were successful
CI/CD / test (push) Successful in 18s
CI/CD / deploy (push) Successful in 2s
feat: task comments/discussion system
- New task_comments table (separate from progress notes)
- Backend: GET/POST/DELETE /api/tasks/:id/comments with session + bearer auth
- TaskComments component on TaskPage (full-page view) with markdown support,
  author avatars, delete own comments, 30s polling
- CompactComments in TaskDetailPanel (side panel) with last 3 + expand
- Comment API functions in frontend lib/api.ts
2026-01-30 00:04:38 +00:00

730 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskStatus, Project } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "../components/Toast";
import { TaskComments } from "../components/TaskComments";
const priorityColors: Record<string, 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 statusColors: Record<string, string> = {
active: "bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 border-amber-300 dark:border-amber-700",
queued: "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700",
blocked: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-300 dark:border-red-700",
completed: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700",
cancelled: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600",
};
const statusIcons: Record<string, string> = {
active: "⚡",
queued: "📋",
blocked: "🚫",
completed: "✅",
cancelled: "❌",
};
const statusActions: Record<string, { label: string; next: TaskStatus; color: string }[]> = {
active: [
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" },
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40" },
{ label: "✅ Complete", next: "completed", color: "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-200 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/40" },
],
queued: [
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40" },
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40" },
],
blocked: [
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40" },
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" },
],
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" }],
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" }],
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "2-digit", minute: "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`;
}
// Markdown prose classes for descriptions and notes
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_pre]:bg-gray-800 dark:[&_pre]:bg-gray-900 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 dark:[&_blockquote]:border-gray-600 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500 dark:[&_blockquote]:text-gray-400";
export function TaskPage() {
const { taskRef } = useParams<{ taskRef: string }>();
const [task, setTask] = useState<Task | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [noteText, setNoteText] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
const [addingSubtask, setAddingSubtask] = useState(false);
const [saving, setSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
// Description editing
const [editingDescription, setEditingDescription] = useState(false);
const [descriptionDraft, setDescriptionDraft] = useState("");
const [savingDescription, setSavingDescription] = useState(false);
// Title editing
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [savingTitle, setSavingTitle] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const fetchTask = useCallback(async () => {
if (!taskRef) return;
try {
const res = await fetch(`/api/tasks/${taskRef}`, { credentials: "include" });
if (!res.ok) throw new Error(res.status === 404 ? "Task not found" : "Failed to load task");
const data = await res.json();
setTask(data);
setError(null);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
}, [taskRef]);
useEffect(() => {
fetchTask();
fetchProjects().then(setProjects).catch(() => {});
}, [fetchTask]);
// Auto-refresh every 15s
useEffect(() => {
const interval = setInterval(fetchTask, 15000);
return () => clearInterval(interval);
}, [fetchTask]);
const handleStatusChange = async (status: TaskStatus) => {
if (!task) return;
setSaving(true);
try {
await updateTask(task.id, { status });
fetchTask();
toast(`Task moved to ${status}`, "success");
} catch (e) {
console.error("Failed to update status:", e);
toast("Failed to update status", "error");
} finally {
setSaving(false);
}
};
const handleSaveDescription = async () => {
if (!task) return;
setSavingDescription(true);
try {
await updateTask(task.id, { description: descriptionDraft });
fetchTask();
setEditingDescription(false);
toast("Description updated", "success");
} catch (e) {
console.error("Failed to save description:", e);
toast("Failed to save description", "error");
} finally {
setSavingDescription(false);
}
};
const handleSaveTitle = async () => {
if (!task || !titleDraft.trim()) return;
setSavingTitle(true);
try {
await updateTask(task.id, { title: titleDraft.trim() });
fetchTask();
setEditingTitle(false);
toast("Title updated", "success");
} catch (e) {
console.error("Failed to save title:", e);
toast("Failed to save title", "error");
} finally {
setSavingTitle(false);
}
};
const handleDelete = async () => {
if (!task) return;
setDeleting(true);
try {
await deleteTask(task.id);
toast("Task deleted", "success");
navigate("/queue");
} catch (e) {
console.error("Failed to delete:", e);
toast("Failed to delete task", "error");
} finally {
setDeleting(false);
setShowDeleteConfirm(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
Loading task...
</div>
);
}
if (error || !task) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<span className="text-4xl block mb-3">😕</span>
<p className="text-gray-500 dark:text-gray-400 mb-4">{error || "Task not found"}</p>
<Link to="/queue" className="text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium text-sm">
Back to Queue
</Link>
</div>
</div>
);
}
const isActive = task.status === "active";
const actions = statusActions[task.status] || [];
const project = projects.find((p) => p.id === task.projectId);
const subtaskProgress = task.subtasks?.length > 0
? Math.round((task.subtasks.filter(s => s.completed).length / task.subtasks.length) * 100)
: 0;
return (
<div className="min-h-screen">
{/* Header */}
<header className={`sticky top-14 md:top-0 z-30 border-b ${
isActive
? "bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-amber-200 dark:border-amber-800"
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800"
}`}>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center gap-2 mb-2">
<Link to="/queue" className="text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition">
Queue
</Link>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded font-mono">
HQ-{task.taskNumber}
</span>
</div>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{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]}`}>
{task.priority}
</span>
{project && (
<span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
📁 {project.name}
</span>
)}
{task.recurrence && (
<span className="text-xs text-teal-600 dark:text-teal-400 bg-teal-100 dark:bg-teal-900/30 px-2 py-0.5 rounded-full font-medium">
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
</span>
)}
{task.tags?.map(tag => (
<span key={tag} className="text-xs text-violet-600 dark:text-violet-400 bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 rounded-full font-medium">
🏷 {tag}
</span>
))}
</div>
{editingTitle ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
className="text-xl font-bold text-gray-900 dark:text-gray-100 bg-transparent border-b-2 border-amber-400 dark:border-amber-500 outline-none flex-1 py-0.5"
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
disabled={savingTitle}
/>
<button
onClick={handleSaveTitle}
disabled={savingTitle || !titleDraft.trim()}
className="text-xs px-2.5 py-1 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50"
>
{savingTitle ? "..." : "Save"}
</button>
<button
onClick={() => setEditingTitle(false)}
className="text-xs px-2.5 py-1 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
Cancel
</button>
</div>
) : (
<h1
className="text-xl font-bold text-gray-900 dark:text-gray-100 cursor-pointer hover:text-amber-700 dark:hover:text-amber-400 transition group"
onClick={() => { setTitleDraft(task.title); setEditingTitle(true); }}
title="Click to edit title"
>
{task.title}
<span className="opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 text-sm ml-2 transition"></span>
</h1>
)}
</div>
{/* Status Actions */}
<div className="flex gap-2 shrink-0 flex-wrap justify-end">
{actions.map((action) => (
<button
key={action.next}
onClick={() => handleStatusChange(action.next)}
disabled={saving}
className={`text-sm px-3 py-1.5 rounded-lg border font-medium transition ${action.color} disabled:opacity-50`}
>
{action.label}
</button>
))}
</div>
</div>
{/* Due date badge */}
{task.dueDate && (() => {
const due = new Date(task.dueDate);
const now = new Date();
const diffMs = due.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const isOverdue = diffMs < 0;
const isDueSoon = diffDays <= 2 && !isOverdue;
return (
<div className="mt-2 flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">📅 Due:</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
isOverdue
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
: isDueSoon
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}>
{formatDate(task.dueDate)} ({isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d left`})
</span>
</div>
);
})()}
</div>
</header>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Description */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Description</h2>
{!editingDescription && (
<button
onClick={() => { setDescriptionDraft(task.description || ""); setEditingDescription(true); }}
className="text-xs text-gray-400 dark:text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 transition font-medium"
>
Edit
</button>
)}
</div>
{editingDescription ? (
<div className="space-y-3">
<textarea
autoFocus
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
rows={8}
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-y min-h-[120px] font-mono placeholder:text-gray-400 dark:placeholder:text-gray-500"
placeholder="Describe the task... (Markdown supported)"
/>
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported</span>
<div className="flex gap-2">
<button
onClick={() => setEditingDescription(false)}
className="text-xs px-3 py-1.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
Cancel
</button>
<button
onClick={handleSaveDescription}
disabled={savingDescription}
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50"
>
{savingDescription ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
) : task.description ? (
<div className={`text-sm leading-relaxed ${proseClasses}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{task.description}
</ReactMarkdown>
</div>
) : (
<p
className="text-sm text-gray-400 dark:text-gray-500 italic cursor-pointer hover:text-amber-500 dark:hover:text-amber-400 transition py-4 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg"
onClick={() => { setDescriptionDraft(""); setEditingDescription(true); }}
>
No description click to add one
</p>
)}
</div>
{/* Subtasks */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
Subtasks {task.subtasks?.length > 0 && (
<span className="text-gray-300 dark:text-gray-600 ml-1">({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length})</span>
)}
</h2>
{task.subtasks?.length > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500">{subtaskProgress}%</span>
)}
</div>
{/* Progress bar */}
{task.subtasks?.length > 0 && (
<div className="mb-4">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${subtaskProgress}%` }}
/>
</div>
</div>
)}
{/* Subtask items */}
{task.subtasks?.length > 0 && (
<div className="space-y-1 mb-4">
{task.subtasks.map((subtask) => (
<div key={subtask.id} className="flex items-center gap-3 group py-1.5 px-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition">
<button
onClick={async () => {
try {
await toggleSubtask(task.id, subtask.id, !subtask.completed);
fetchTask();
} catch (e) {
console.error("Failed to toggle subtask:", e);
}
}}
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition ${
subtask.completed
? "bg-green-500 border-green-500 text-white"
: "border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500"
}`}
>
{subtask.completed && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={`text-sm flex-1 ${subtask.completed ? "line-through text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-300"}`}>
{subtask.title}
</span>
<button
onClick={async () => {
try {
await deleteSubtask(task.id, subtask.id);
fetchTask();
} catch (e) {
console.error("Failed to delete subtask:", e);
}
}}
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition p-0.5"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Add subtask */}
<div className="flex gap-2">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Add a subtask..."
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 placeholder:text-gray-400 dark:placeholder:text-gray-500"
onKeyDown={async (e) => {
if (e.key === "Enter" && newSubtaskTitle.trim()) {
setAddingSubtask(true);
try {
await addSubtask(task.id, newSubtaskTitle.trim());
setNewSubtaskTitle("");
fetchTask();
} catch (err) {
console.error("Failed to add subtask:", err);
} finally {
setAddingSubtask(false);
}
}
}}
disabled={addingSubtask}
/>
<button
onClick={async () => {
if (!newSubtaskTitle.trim()) return;
setAddingSubtask(true);
try {
await addSubtask(task.id, newSubtaskTitle.trim());
setNewSubtaskTitle("");
fetchTask();
} catch (err) {
console.error("Failed to add subtask:", err);
} finally {
setAddingSubtask(false);
}
}}
disabled={!newSubtaskTitle.trim() || addingSubtask}
className="px-4 py-2 bg-amber-500 text-white text-sm rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{addingSubtask ? "..." : "+ Add"}
</button>
</div>
</div>
{/* Progress Notes */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
Progress Notes {task.progressNotes?.length > 0 && (
<span className="text-gray-300 dark:text-gray-600 ml-1">({task.progressNotes.length})</span>
)}
</h2>
{/* Add note */}
<div className="mb-4">
<div className="flex gap-2">
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
placeholder="Add a progress note..."
rows={2}
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-y min-h-[40px] max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (noteText.trim()) {
setAddingNote(true);
addProgressNote(task.id, noteText.trim())
.then(() => { setNoteText(""); fetchTask(); })
.catch((err) => console.error("Failed to add note:", err))
.finally(() => setAddingNote(false));
}
}
}}
/>
<button
onClick={() => {
if (!noteText.trim()) return;
setAddingNote(true);
addProgressNote(task.id, noteText.trim())
.then(() => { setNoteText(""); fetchTask(); })
.catch((err) => console.error("Failed to add note:", err))
.finally(() => setAddingNote(false));
}}
disabled={!noteText.trim() || addingNote}
className="self-end px-4 py-2 bg-amber-500 text-white text-sm rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{addingNote ? "..." : "Add"}
</button>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">+Enter to submit</p>
</div>
{/* Notes list */}
{!task.progressNotes || task.progressNotes.length === 0 ? (
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 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">
{i < task.progressNotes.length - 1 && (
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-last:hidden" />
)}
<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 dark:border-amber-500 bg-amber-50 dark:bg-amber-900/30"
: "border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900"
}`}>
<div className={`w-2 h-2 rounded-full ${i === 0 && isActive ? "bg-amber-500" : "bg-gray-300 dark:bg-gray-600"}`} />
</div>
<div>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{note.note}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{formatDate(note.timestamp)} · {timeAgo(note.timestamp)}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* Comments / Discussion */}
<TaskComments taskId={task.id} />
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Meta */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Details</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Source</span>
<span className="text-gray-700 dark:text-gray-300 font-medium capitalize">{task.source}</span>
</div>
{task.assigneeName && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Assignee</span>
<span className="text-gray-700 dark:text-gray-300 font-medium">{task.assigneeName}</span>
</div>
)}
{project && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Project</span>
<span className="text-gray-700 dark:text-gray-300 font-medium">{project.name}</span>
</div>
)}
{task.estimatedHours != null && task.estimatedHours > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Estimate</span>
<span className="text-gray-700 dark:text-gray-300 font-medium"> {task.estimatedHours}h</span>
</div>
)}
{task.recurrence && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Recurrence</span>
<span className="text-teal-600 dark:text-teal-400 font-medium">
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
</span>
</div>
)}
{task.tags?.length > 0 && (
<div>
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>
<div className="flex flex-wrap gap-1">
{task.tags.map(tag => (
<span key={tag} className="text-[10px] text-violet-600 dark:text-violet-400 bg-violet-100 dark:bg-violet-900/30 px-1.5 py-0.5 rounded-full font-medium">
🏷 {tag}
</span>
))}
</div>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Created</span>
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.createdAt)}</span>
</div>
{task.updatedAt !== task.createdAt && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Updated</span>
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.updatedAt)}</span>
</div>
)}
{task.completedAt && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Completed</span>
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.completedAt)}</span>
</div>
)}
</div>
</div>
{/* Quick link */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Share</h3>
<div className="flex items-center gap-2">
<code className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-800 px-2 py-1 rounded flex-1 truncate">
/task/HQ-{task.taskNumber}
</code>
<button
onClick={() => {
const url = `${window.location.origin}/task/HQ-${task.taskNumber}`;
navigator.clipboard.writeText(url);
toast("Link copied", "info");
}}
className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
📋
</button>
</div>
</div>
{/* Danger zone */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Danger Zone</h3>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 transition font-medium"
>
🗑 Delete Task
</button>
) : (
<div className="space-y-2 animate-slide-up">
<p className="text-xs text-red-700 dark:text-red-400">This cannot be undone. Are you sure?</p>
<div className="flex gap-2">
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 text-sm px-3 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition disabled:opacity-50"
>
{deleting ? "Deleting..." : "Yes, delete"}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 text-sm px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}