diff --git a/backend/drizzle/0001_mighty_callisto.sql b/backend/drizzle/0001_mighty_callisto.sql new file mode 100644 index 0000000..509148d --- /dev/null +++ b/backend/drizzle/0001_mighty_callisto.sql @@ -0,0 +1,2 @@ +ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS "due_date" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "tasks" ADD COLUMN IF NOT EXISTS "subtasks" jsonb DEFAULT '[]'::jsonb; \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..8ae7056 --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,576 @@ +{ + "id": "00cb3823-7607-4e8d-ba6a-694ca93dc3d8", + "prevId": "bf5b044c-8c7f-4838-8a0d-8afbfd63d05c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repos": { + "name": "repos", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_number": { + "name": "task_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "task_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'donovan'" + }, + "status": { + "name": "status", + "type": "task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_name": { + "name": "assignee_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subtasks": { + "name": "subtasks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "progress_notes": { + "name": "progress_notes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "critical", + "high", + "medium", + "low" + ] + }, + "public.task_source": { + "name": "task_source", + "schema": "public", + "values": [ + "donovan", + "david", + "hammer", + "heartbeat", + "cron", + "other" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "active", + "queued", + "blocked", + "completed", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index d2462e4..d1d6fc8 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769658956087, "tag": "0000_grey_starhawk", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1769670192629, + "tag": "0001_mighty_callisto", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f82e3fd..58c7725 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -38,6 +38,14 @@ export interface ProgressNote { note: string; } +export interface Subtask { + id: string; + title: string; + completed: boolean; + completedAt?: string; + createdAt: string; +} + // ─── Projects ─── export interface ProjectLink { @@ -73,6 +81,8 @@ export const tasks = pgTable("tasks", { assigneeId: text("assignee_id"), assigneeName: text("assignee_name"), projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + dueDate: timestamp("due_date", { withTimezone: true }), + subtasks: jsonb("subtasks").$type().default([]), progressNotes: jsonb("progress_notes").$type().default([]), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index fd1eae6..3e2d28e 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -1,6 +1,6 @@ import { Elysia, t } from "elysia"; import { db } from "../db"; -import { tasks, type ProgressNote } from "../db/schema"; +import { tasks, type ProgressNote, type Subtask } from "../db/schema"; import { eq, asc, desc, sql, inArray, or } from "drizzle-orm"; import { auth } from "../lib/auth"; @@ -153,6 +153,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) position: (maxPos[0]?.max ?? 0) + 1, taskNumber: nextNumber, projectId: body.projectId || null, + dueDate: body.dueDate ? new Date(body.dueDate) : null, + subtasks: [], progressNotes: [], }) .returning(); @@ -190,6 +192,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) ]) ), projectId: t.Optional(t.Union([t.String(), t.Null()])), + dueDate: t.Optional(t.Union([t.String(), t.Null()])), }), } ) @@ -236,6 +239,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) 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.subtasks !== undefined) updates.subtasks = body.subtasks; const updated = await db .update(tasks) @@ -263,10 +268,107 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) 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()])), + subtasks: t.Optional(t.Array(t.Object({ + id: t.String(), + title: t.String(), + completed: t.Boolean(), + completedAt: t.Optional(t.String()), + createdAt: 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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa8fcce..3a7587f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { DashboardPage } from "./pages/DashboardPage"; import { QueuePage } from "./pages/QueuePage"; import { ChatPage } from "./pages/ChatPage"; import { ProjectsPage } from "./pages/ProjectsPage"; +import { TaskPage } from "./pages/TaskPage"; import { AdminPage } from "./components/AdminPage"; import { LoginPage } from "./components/LoginPage"; import { useSession } from "./lib/auth-client"; @@ -15,6 +16,7 @@ function AuthenticatedApp() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index 45cdc28..569ec38 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types"; -import { updateTask, fetchProjects, addProgressNote } from "../lib/api"; +import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api"; const priorityColors: Record = { critical: "bg-red-500 text-white", @@ -253,7 +253,10 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [draftPriority, setDraftPriority] = useState(task.priority); const [draftSource, setDraftSource] = useState(task.source); const [draftProjectId, setDraftProjectId] = useState(task.projectId || ""); + const [draftDueDate, setDraftDueDate] = useState(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); const [projects, setProjects] = useState([]); + const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); + const [addingSubtask, setAddingSubtask] = useState(false); // Fetch projects for the selector useEffect(() => { @@ -267,15 +270,18 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftPriority(task.priority); setDraftSource(task.source); setDraftProjectId(task.projectId || ""); - }, [task.id, task.title, task.description, task.priority, task.source, task.projectId]); + setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); + }, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate]); // Detect if any field has been modified + const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""; const isDirty = draftTitle !== task.title || draftDescription !== (task.description || "") || draftPriority !== task.priority || draftSource !== task.source || - draftProjectId !== (task.projectId || ""); + draftProjectId !== (task.projectId || "") || + draftDueDate !== currentDueDate; const handleCancel = () => { setDraftTitle(task.title); @@ -283,6 +289,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftPriority(task.priority); setDraftSource(task.source); setDraftProjectId(task.projectId || ""); + setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); }; const handleSave = async () => { @@ -295,6 +302,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, if (draftPriority !== task.priority) updates.priority = draftPriority; if (draftSource !== task.source) updates.source = draftSource; if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null; + if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null; await updateTask(task.id, updates, token); onTaskUpdated(); } catch (e) { @@ -462,6 +470,49 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, )} + {/* Due Date */} +
+

Due Date

+ {hasToken ? ( +
+ setDraftDueDate(e.target.value)} + className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white" + /> + {draftDueDate && ( + + )} + {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 ( + + {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "Due today" : diffDays === 1 ? "Due tomorrow" : `${diffDays}d left`} + + ); + })()} +
+ ) : ( + + {task.dueDate ? new Date(task.dueDate).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : No due date} + + )} +
+ {/* Description */}

Description

@@ -509,6 +560,128 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
+ {/* Subtasks */} +
+

+ Subtasks {task.subtasks?.length > 0 && ( + + ({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}) + + )} +

+ + {/* Subtask progress bar */} + {task.subtasks?.length > 0 && ( +
+
+
s.completed).length / task.subtasks.length) * 100}%` }} + /> +
+
+ )} + + {/* Subtask list */} + {task.subtasks?.length > 0 && ( +
+ {task.subtasks.map((subtask) => ( +
+ + + {subtask.title} + + {hasToken && ( + + )} +
+ ))} +
+ )} + + {/* Add subtask input */} + {hasToken && ( +
+ setNewSubtaskTitle(e.target.value)} + placeholder="Add a subtask..." + className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300" + onKeyDown={async (e) => { + if (e.key === "Enter" && newSubtaskTitle.trim()) { + setAddingSubtask(true); + try { + await addSubtask(task.id, newSubtaskTitle.trim()); + setNewSubtaskTitle(""); + onTaskUpdated(); + } catch (err) { + console.error("Failed to add subtask:", err); + } finally { + setAddingSubtask(false); + } + } + }} + disabled={addingSubtask} + /> + +
+ )} +
+ {/* Progress Notes */}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b808071..bd8817e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -115,6 +115,38 @@ export async function deleteProject(id: string): Promise { if (!res.ok) throw new Error("Failed to delete project"); } +// Subtasks +export async function addSubtask(taskId: string, title: string): Promise { + const res = await fetch(`${BASE}/${taskId}/subtasks`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + if (!res.ok) throw new Error("Failed to add subtask"); + return res.json(); +} + +export async function toggleSubtask(taskId: string, subtaskId: string, completed: boolean): Promise { + const res = await fetch(`${BASE}/${taskId}/subtasks/${subtaskId}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ completed }), + }); + if (!res.ok) throw new Error("Failed to toggle subtask"); + return res.json(); +} + +export async function deleteSubtask(taskId: string, subtaskId: string): Promise { + const res = await fetch(`${BASE}/${taskId}/subtasks/${subtaskId}`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to delete subtask"); + return res.json(); +} + // Progress Notes export async function addProgressNote(taskId: string, note: string): Promise { const res = await fetch(`${BASE}/${taskId}/notes`, { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 612be50..ee5b0a7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -7,6 +7,14 @@ export interface ProgressNote { note: string; } +export interface Subtask { + id: string; + title: string; + completed: boolean; + completedAt?: string; + createdAt: string; +} + export interface ProjectLink { label: string; url: string; @@ -36,7 +44,11 @@ export interface Task { status: TaskStatus; priority: TaskPriority; position: number; + assigneeId: string | null; + assigneeName: string | null; projectId: string | null; + dueDate: string | null; + subtasks: Subtask[]; progressNotes: ProgressNote[]; createdAt: string; updatedAt: string; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0040b9b..d141085 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -137,7 +137,7 @@ export function DashboardPage() { ) : (
{activeTasks.map((task) => ( - +
@@ -146,8 +146,27 @@ export function DashboardPage() { HQ-{task.taskNumber} {task.priority} + {task.dueDate && (() => { + const due = new Date(task.dueDate); + const diffMs = due.getTime() - Date.now(); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + const isOverdue = diffMs < 0; + return isOverdue || diffDays <= 2 ? ( + + 📅 {isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`} + + ) : null; + })()}

{task.title}

+ {task.subtasks?.length > 0 && ( +
+
+
s.completed).length / task.subtasks.length) * 100}%` }} /> +
+ {task.subtasks.filter(s => s.completed).length}/{task.subtasks.length} +
+ )} {task.progressNotes?.length > 0 && (

Latest: {task.progressNotes[task.progressNotes.length - 1].note} diff --git a/frontend/src/pages/TaskPage.tsx b/frontend/src/pages/TaskPage.tsx new file mode 100644 index 0000000..cc0f11d --- /dev/null +++ b/frontend/src/pages/TaskPage.tsx @@ -0,0 +1,493 @@ +import { useState, useEffect, useCallback } from "react"; +import { useParams, Link } from "react-router-dom"; +import type { Task, TaskStatus, Project } from "../lib/types"; +import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api"; + +const priorityColors: Record = { + 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 = { + 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 = { + active: "⚡", + queued: "📋", + blocked: "🚫", + completed: "✅", + cancelled: "❌", +}; + +const statusActions: Record = { + 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" }, + ], + 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" }, + ], + 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" }, + ], + 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", 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`; +} + +export function TaskPage() { + const { taskRef } = useParams<{ taskRef: string }>(); + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [projects, setProjects] = useState([]); + const [noteText, setNoteText] = useState(""); + const [addingNote, setAddingNote] = useState(false); + const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); + const [addingSubtask, setAddingSubtask] = useState(false); + const [saving, setSaving] = useState(false); + + 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(); + } catch (e) { + console.error("Failed to update status:", e); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +

+ Loading task... +
+ ); + } + + if (error || !task) { + return ( +
+
+ 😕 +

{error || "Task not found"}

+ + ← Back to Queue + +
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+
+ + ← Queue + + / + + HQ-{task.taskNumber} + +
+
+
+
+ {isActive && ( + + + + + )} + + {statusIcons[task.status]} {task.status.toUpperCase()} + + + {task.priority} + + {project && ( + + 📁 {project.name} + + )} +
+

{task.title}

+
+ {/* Status Actions */} +
+ {actions.map((action) => ( + + ))} +
+
+ {/* 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 ( +
+ 📅 Due: + + {formatDate(task.dueDate)} ({isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d left`}) + +
+ ); + })()} +
+
+ +
+
+ {/* Main content */} +
+ {/* Description */} +
+

Description

+

+ {task.description || No description} +

+
+ + {/* Subtasks */} +
+
+

+ Subtasks {task.subtasks?.length > 0 && ( + ({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}) + )} +

+ {task.subtasks?.length > 0 && ( + {subtaskProgress}% + )} +
+ + {/* Progress bar */} + {task.subtasks?.length > 0 && ( +
+
+
+
+
+ )} + + {/* Subtask items */} + {task.subtasks?.length > 0 && ( +
+ {task.subtasks.map((subtask) => ( +
+ + + {subtask.title} + + +
+ ))} +
+ )} + + {/* Add subtask */} +
+ setNewSubtaskTitle(e.target.value)} + placeholder="Add a subtask..." + className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300" + 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} + /> + +
+
+ + {/* Progress Notes */} +
+

+ Progress Notes {task.progressNotes?.length > 0 && ( + ({task.progressNotes.length}) + )} +

+ + {/* Add note */} +
+
+