feat: task comments/discussion system
All checks were successful
CI/CD / test (push) Successful in 18s
CI/CD / deploy (push) Successful in 2s

- 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
This commit is contained in:
2026-01-30 00:04:38 +00:00
parent 46002e0854
commit b7ff8437e4
7 changed files with 477 additions and 1 deletions

View File

@@ -0,0 +1,207 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { fetchComments, addComment, deleteComment, type TaskComment } from "../lib/api";
import { useCurrentUser } from "../hooks/useCurrentUser";
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 formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
// Avatar color based on name
function avatarColor(name: string): string {
const colors = [
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
function avatarInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ol]:mb-1 [&_li]:mb-0 [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline";
export function TaskComments({ taskId }: { taskId: string }) {
const [comments, setComments] = useState<TaskComment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { user } = useCurrentUser();
const loadComments = useCallback(async () => {
try {
const data = await fetchComments(taskId);
setComments(data);
} catch (e) {
console.error("Failed to load comments:", e);
} finally {
setLoading(false);
}
}, [taskId]);
useEffect(() => {
loadComments();
// Poll for new comments every 30s
const interval = setInterval(loadComments, 30000);
return () => clearInterval(interval);
}, [loadComments]);
const handleSubmit = async () => {
const text = commentText.trim();
if (!text) return;
setSubmitting(true);
try {
await addComment(taskId, text);
setCommentText("");
await loadComments();
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "42px";
}
} catch (e) {
console.error("Failed to add comment:", e);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
setDeletingId(commentId);
try {
await deleteComment(taskId, commentId);
setComments((prev) => prev.filter((c) => c.id !== commentId));
} catch (e) {
console.error("Failed to delete comment:", e);
} finally {
setDeletingId(null);
}
};
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "42px";
el.style.height = Math.min(el.scrollHeight, 160) + "px";
};
return (
<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">
💬 Discussion {comments.length > 0 && (
<span className="text-gray-300 dark:text-gray-600 ml-1">({comments.length})</span>
)}
</h2>
{/* Comment input */}
<div className="mb-4">
<div className="flex gap-3">
{user && (
<div className={`w-8 h-8 rounded-full ${avatarColor(user.name)} flex items-center justify-center text-white text-sm font-bold shrink-0 mt-1`}>
{avatarInitial(user.name)}
</div>
)}
<div className="flex-1">
<textarea
ref={textareaRef}
value={commentText}
onChange={(e) => { setCommentText(e.target.value); autoResize(); }}
placeholder="Leave a comment..."
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-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
style={{ minHeight: "42px" }}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex items-center justify-between mt-1.5">
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported · +Enter to submit</span>
<button
onClick={handleSubmit}
disabled={!commentText.trim() || submitting}
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 disabled:cursor-not-allowed"
>
{submitting ? "Posting..." : "Comment"}
</button>
</div>
</div>
</div>
</div>
{/* Comments list */}
{loading ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-6 text-sm">Loading comments...</div>
) : comments.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 comments yet be the first to chime in
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => {
const isOwn = user && comment.authorId === user.id;
const isHammer = comment.authorId === "hammer";
return (
<div key={comment.id} className="flex gap-3 group">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0 mt-0.5 ${
isHammer
? "bg-amber-500 text-white"
: `${avatarColor(comment.authorName)} text-white`
}`}>
{isHammer ? "🔨" : avatarInitial(comment.authorName)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-sm font-semibold ${
isHammer ? "text-amber-700 dark:text-amber-400" : "text-gray-800 dark:text-gray-200"
}`}>
{comment.authorName}
</span>
<span className="text-[10px] text-gray-400 dark:text-gray-500" title={formatDate(comment.createdAt)}>
{timeAgo(comment.createdAt)}
</span>
{isOwn && (
<button
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition text-xs"
title="Delete comment"
>
{deletingId === comment.id ? "..." : "×"}
</button>
)}
</div>
<div className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${proseClasses}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.content}
</ReactMarkdown>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask, fetchComments, addComment, type TaskComment } from "../lib/api";
import { useToast } from "./Toast";
const priorityColors: Record<TaskPriority, string> = {
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
token: string;
}
function CompactComments({ taskId }: { taskId: string }) {
const [comments, setComments] = useState<TaskComment[]>([]);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
fetchComments(taskId).then(setComments).catch(() => {});
}, [taskId]);
const handleSubmit = async () => {
if (!text.trim()) return;
setSubmitting(true);
try {
const comment = await addComment(taskId, text.trim());
setComments((prev) => [...prev, comment]);
setText("");
} catch (e) {
console.error("Failed to add comment:", e);
} finally {
setSubmitting(false);
}
};
const recentComments = expanded ? comments : comments.slice(-3);
return (
<div className="border-t border-gray-100 dark:border-gray-800 pt-4 mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
💬 Discussion {comments.length > 0 && <span className="text-gray-300 dark:text-gray-600">({comments.length})</span>}
</h3>
{comments.length > 3 && !expanded && (
<button onClick={() => setExpanded(true)} className="text-[10px] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
Show all ({comments.length})
</button>
)}
</div>
{/* Comments */}
{recentComments.length > 0 && (
<div className="space-y-2 mb-3">
{recentComments.map((c) => (
<div key={c.id} className="text-xs">
<span className={`font-semibold ${c.authorId === "hammer" ? "text-amber-700 dark:text-amber-400" : "text-gray-700 dark:text-gray-300"}`}>
{c.authorName}
</span>
<span className="text-gray-400 dark:text-gray-500 ml-1.5">
{timeAgo(c.createdAt)}
</span>
<p className="text-gray-600 dark:text-gray-400 mt-0.5 leading-relaxed">{c.content}</p>
</div>
))}
</div>
)}
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a comment..."
className="flex-1 text-xs border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 placeholder:text-gray-400 dark:placeholder:text-gray-500"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
disabled={submitting}
/>
<button
onClick={handleSubmit}
disabled={!text.trim() || submitting}
className="text-xs px-2.5 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{submitting ? "..." : "Post"}
</button>
</div>
</div>
);
}
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
const actions = statusActions[task.status] || [];
const isActive = task.status === "active";
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div>
)}
</div>
{/* Quick Comments */}
<CompactComments taskId={task.id} />
</div>
{/* Save / Cancel Bar */}

View File

@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
return res.json();
}
// ─── Comments API ───
export interface TaskComment {
id: string;
taskId: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
}
export async function fetchComments(taskId: string): Promise<TaskComment[]> {
const res = await fetch(`${BASE}/${taskId}/comments`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch comments");
return res.json();
}
export async function addComment(taskId: string, content: string): Promise<TaskComment> {
const res = await fetch(`${BASE}/${taskId}/comments`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to add comment");
return res.json();
}
export async function deleteComment(taskId: string, commentId: string): Promise<void> {
const res = await fetch(`${BASE}/${taskId}/comments/${commentId}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete comment");
}
// Admin API
export async function fetchUsers(): Promise<any[]> {
const res = await fetch("/api/admin/users", { credentials: "include" });

View File

@@ -5,6 +5,7 @@ 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",
@@ -598,6 +599,8 @@ export function TaskPage() {
</div>
)}
</div>
{/* Comments / Discussion */}
<TaskComments taskId={task.id} />
</div>
{/* Sidebar */}