Files
hammer-queue/backend/src/routes/tasks.ts
Hammer dd401290c1 feat: task time estimates and velocity chart on dashboard
- Added estimatedHours column to tasks schema
- Backend: create/update support for estimatedHours
- New /api/tasks/stats/velocity endpoint: daily completions, weekly velocity, estimate totals
- Dashboard: velocity chart with 7-day bar chart, this week count, avg/week, estimate summary
- TaskDetailPanel: estimated hours input field
- CreateTaskModal: estimated hours in advanced options
- TaskCard, KanbanBoard, TaskPage: estimate badge display
2026-01-29 11:35:50 +00:00

505 lines
17 KiB
TypeScript

import { Elysia, t } from "elysia";
import { db } from "../db";
import { tasks, type ProgressNote, type Subtask } from "../db/schema";
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
import { auth } from "../lib/auth";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/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_URL || !CLAWDBOT_HOOK_TOKEN) {
console.warn("CLAWDBOT_HOOK_URL or CLAWDBOT_HOOK_TOKEN not set — skipping webhook");
return;
}
if (!CLAWDBOT_HOOK_URL.startsWith("https://")) {
console.warn("CLAWDBOT_HOOK_URL must use HTTPS — skipping webhook");
return;
}
try {
const message = `🔨 Task activated in Hammer Dashboard:\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 dashboard API as you work:\ncurl -s -H "Authorization: Bearer $HAMMER_QUEUE_API_KEY" -H "Content-Type: application/json" -X POST "https://dash.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
const statusOrder = sql`CASE
WHEN ${tasks.status} = 'active' THEN 0
WHEN ${tasks.status} = 'queued' THEN 1
WHEN ${tasks.status} = 'blocked' THEN 2
WHEN ${tasks.status} = 'completed' THEN 3
WHEN ${tasks.status} = 'cancelled' THEN 4
ELSE 5 END`;
function requireBearerAuth(headers: Record<string, string | undefined>) {
const authHeader = headers["authorization"];
if (!authHeader || authHeader !== `Bearer ${BEARER_TOKEN}`) {
throw new Error("Unauthorized");
}
}
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
// Check bearer token first
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
// Check session
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session) return;
} catch {
// Session check failed
}
throw new Error("Unauthorized");
}
async function requireAdmin(request: Request, headers: Record<string, string | undefined>) {
// Bearer token = admin access
const authHeader = headers["authorization"];
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
// Check session + role
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user && (session.user as any).role === "admin") return;
} catch {}
throw new Error("Unauthorized");
}
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
async function resolveTask(idOrNumber: string) {
// Strip "HQ-" prefix if present
const cleaned = idOrNumber.replace(/^HQ-/i, "");
const asNumber = parseInt(cleaned, 10);
let result;
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
// Lookup by task_number
result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber));
} else {
// Lookup by UUID
result = await db.select().from(tasks).where(eq(tasks.id, cleaned));
}
return result[0] || null;
}
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.onError(({ error, set }) => {
const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
if (msg === "Task not found") {
set.status = 404;
return { error: "Task not found" };
}
console.error("Task route error:", msg);
set.status = 500;
return { error: "Internal server error" };
})
// GET all tasks - requires session or bearer auth
.get("/", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
const allTasks = await db
.select()
.from(tasks)
.orderBy(statusOrder, asc(tasks.position), desc(tasks.createdAt));
return allTasks;
})
// POST create task - requires session or bearer auth
.post(
"/",
async ({ body, request, headers }) => {
await requireSessionOrBearer(request, headers);
// Get max position for queued tasks
const maxPos = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
.from(tasks);
// Get next task number
const maxNum = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.taskNumber}), 0)` })
.from(tasks);
const nextNumber = (maxNum[0]?.max ?? 0) + 1;
const newTask = await db
.insert(tasks)
.values({
title: body.title,
description: body.description,
source: body.source || "donovan",
status: body.status || "queued",
priority: body.priority || "medium",
position: (maxPos[0]?.max ?? 0) + 1,
taskNumber: nextNumber,
projectId: body.projectId || null,
dueDate: body.dueDate ? new Date(body.dueDate) : null,
estimatedHours: body.estimatedHours ?? null,
tags: body.tags || [],
subtasks: [],
progressNotes: [],
})
.returning();
return newTask[0];
},
{
body: t.Object({
title: t.String(),
description: t.Optional(t.String()),
source: t.Optional(
t.Union([
t.Literal("donovan"),
t.Literal("david"),
t.Literal("hammer"),
t.Literal("heartbeat"),
t.Literal("cron"),
t.Literal("other"),
])
),
status: t.Optional(
t.Union([
t.Literal("active"),
t.Literal("queued"),
t.Literal("blocked"),
t.Literal("completed"),
t.Literal("cancelled"),
])
),
priority: t.Optional(
t.Union([
t.Literal("critical"),
t.Literal("high"),
t.Literal("medium"),
t.Literal("low"),
])
),
projectId: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())),
}),
}
)
// GET stats - velocity and estimates - requires auth
.get("/stats/velocity", async ({ request, headers }) => {
await requireSessionOrBearer(request, headers);
const allTasks = await db.select().from(tasks);
// Build daily completion counts for last 14 days
const dailyCompletions: { date: string; count: number }[] = [];
for (let i = 13; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const count = allTasks.filter(t => {
if (!t.completedAt) return false;
const completedDate = new Date(t.completedAt).toISOString().split("T")[0];
return completedDate === dateStr;
}).length;
dailyCompletions.push({ date: dateStr, count });
}
// Estimate totals
const activeAndQueued = allTasks.filter(t => t.status === "active" || t.status === "queued");
const totalEstimated = activeAndQueued.reduce((sum, t) => sum + (t.estimatedHours || 0), 0);
const estimatedCount = activeAndQueued.filter(t => t.estimatedHours).length;
const unestimatedCount = activeAndQueued.length - estimatedCount;
// Completed this week (Mon-Sun)
const now = new Date();
const dayOfWeek = now.getDay();
const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const monday = new Date(now);
monday.setDate(monday.getDate() - mondayOffset);
monday.setHours(0, 0, 0, 0);
const completedThisWeek = allTasks.filter(t => {
if (!t.completedAt) return false;
return new Date(t.completedAt) >= monday;
}).length;
// Average tasks completed per week (last 4 weeks)
const fourWeeksAgo = new Date();
fourWeeksAgo.setDate(fourWeeksAgo.getDate() - 28);
const completedLast4Weeks = allTasks.filter(t => {
if (!t.completedAt) return false;
return new Date(t.completedAt) >= fourWeeksAgo;
}).length;
const avgPerWeek = Math.round((completedLast4Weeks / 4) * 10) / 10;
return {
dailyCompletions,
completedThisWeek,
avgPerWeek,
totalEstimatedHours: totalEstimated,
estimatedTaskCount: estimatedCount,
unestimatedTaskCount: unestimatedCount,
};
})
// GET single task by ID or number - requires session or bearer auth
.get(
"/:id",
async ({ params, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
return task;
},
{ params: t.Object({ id: t.String() }) }
)
// PATCH update task - requires session or bearer auth
.patch(
"/:id",
async ({ params, body, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
const updates: Record<string, any> = { updatedAt: new Date() };
if (body.title !== undefined) updates.title = body.title;
if (body.description !== undefined) updates.description = body.description;
if (body.source !== undefined) updates.source = body.source;
if (body.status !== undefined) {
updates.status = body.status;
if (body.status === "completed" || body.status === "cancelled") {
updates.completedAt = new Date();
}
// If setting to active, deactivate any currently active task
if (body.status === "active") {
await db
.update(tasks)
.set({ status: "queued", updatedAt: new Date() })
.where(eq(tasks.status, "active"));
}
}
if (body.priority !== undefined) updates.priority = body.priority;
if (body.position !== undefined) updates.position = body.position;
if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId;
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
if (body.projectId !== undefined) updates.projectId = body.projectId;
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
if (body.tags !== undefined) updates.tags = body.tags;
if (body.subtasks !== undefined) updates.subtasks = body.subtasks;
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
const updated = await db
.update(tasks)
.set(updates)
.where(eq(tasks.id, task.id))
.returning();
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];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
source: t.Optional(t.String()),
status: t.Optional(t.String()),
priority: t.Optional(t.String()),
position: t.Optional(t.Number()),
assigneeId: t.Optional(t.Union([t.String(), t.Null()])),
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
projectId: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())),
subtasks: t.Optional(t.Array(t.Object({
id: t.String(),
title: t.String(),
completed: t.Boolean(),
completedAt: t.Optional(t.String()),
createdAt: t.String(),
}))),
progressNotes: t.Optional(t.Array(t.Object({
timestamp: t.String(),
note: t.String(),
}))),
}),
}
)
// POST add subtask - requires auth
.post(
"/:id/subtasks",
async ({ params, body, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
const currentSubtasks = (task.subtasks || []) as Subtask[];
const newSubtask: Subtask = {
id: `st-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
title: body.title,
completed: false,
createdAt: new Date().toISOString(),
};
currentSubtasks.push(newSubtask);
const updated = await db
.update(tasks)
.set({ subtasks: currentSubtasks, updatedAt: new Date() })
.where(eq(tasks.id, task.id))
.returning();
return updated[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({ title: t.String() }),
}
)
// PATCH toggle subtask - requires auth
.patch(
"/:id/subtasks/:subtaskId",
async ({ params, body, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
const currentSubtasks = (task.subtasks || []) as Subtask[];
const subtask = currentSubtasks.find((s) => s.id === params.subtaskId);
if (!subtask) throw new Error("Task not found");
if (body.completed !== undefined) {
subtask.completed = body.completed;
subtask.completedAt = body.completed ? new Date().toISOString() : undefined;
}
if (body.title !== undefined) {
subtask.title = body.title;
}
const updated = await db
.update(tasks)
.set({ subtasks: currentSubtasks, updatedAt: new Date() })
.where(eq(tasks.id, task.id))
.returning();
return updated[0];
},
{
params: t.Object({ id: t.String(), subtaskId: t.String() }),
body: t.Object({
completed: t.Optional(t.Boolean()),
title: t.Optional(t.String()),
}),
}
)
// DELETE subtask - requires auth
.delete(
"/:id/subtasks/:subtaskId",
async ({ params, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
const currentSubtasks = (task.subtasks || []) as Subtask[];
const filtered = currentSubtasks.filter((s) => s.id !== params.subtaskId);
const updated = await db
.update(tasks)
.set({ subtasks: filtered, updatedAt: new Date() })
.where(eq(tasks.id, task.id))
.returning();
return updated[0];
},
{
params: t.Object({ id: t.String(), subtaskId: t.String() }),
}
)
// POST add progress note - requires auth
.post(
"/:id/notes",
async ({ params, body, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
const currentNotes = (task.progressNotes || []) as ProgressNote[];
const newNote: ProgressNote = {
timestamp: new Date().toISOString(),
note: body.note,
};
currentNotes.push(newNote);
const updated = await db
.update(tasks)
.set({ progressNotes: currentNotes, updatedAt: new Date() })
.where(eq(tasks.id, task.id))
.returning();
return updated[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({ note: t.String() }),
}
)
// PATCH reorder tasks - requires auth
.patch(
"/reorder",
async ({ body, request, headers }) => {
await requireSessionOrBearer(request, headers);
// body.ids is an ordered array of task IDs
const updates = body.ids.map((id: string, index: number) =>
db
.update(tasks)
.set({ position: index, updatedAt: new Date() })
.where(eq(tasks.id, id))
);
await Promise.all(updates);
return { success: true };
},
{
body: t.Object({ ids: t.Array(t.String()) }),
}
)
// DELETE task - requires auth
.delete(
"/:id",
async ({ params, request, headers }) => {
await requireSessionOrBearer(request, headers);
const task = await resolveTask(params.id);
if (!task) throw new Error("Task not found");
await db.delete(tasks).where(eq(tasks.id, task.id));
return { success: true };
},
{
params: t.Object({ id: t.String() }),
}
);