From 2948eb11395ed874d3bd0daf20beda9a5cd5ab39 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 09:06:44 +0100 Subject: [PATCH] feat: Add tiptap editor for task descriptions in slide-over panel - Add updateTask tRPC mutation (name, description fields) - Replace static description with TiptapEditor in TaskSlideOver - Auto-detect existing markdown descriptions and convert to tiptap JSON - Debounced auto-save with flush on close/unmount - Saving indicator in header --- apps/server/trpc/routers/task.ts | 19 ++++ .../components/execution/TaskSlideOver.tsx | 91 +++++++++++++++---- docs/server-api.md | 1 + 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/apps/server/trpc/routers/task.ts b/apps/server/trpc/routers/task.ts index c0b5413..595323f 100644 --- a/apps/server/trpc/routers/task.ts +++ b/apps/server/trpc/routers/task.ts @@ -143,6 +143,25 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) { return tasks.filter((t) => t.category !== 'detail'); }), + updateTask: publicProcedure + .input(z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + })) + .mutation(async ({ ctx, input }) => { + const taskRepository = requireTaskRepository(ctx); + const existing = await taskRepository.findById(input.id); + if (!existing) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Task '${input.id}' not found`, + }); + } + const { id, ...data } = input; + return taskRepository.update(id, data); + }), + deleteTask: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index 5a29e44..8df2a65 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -1,12 +1,13 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "motion/react"; import { X, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/StatusBadge"; import { StatusDot } from "@/components/StatusDot"; +import { TiptapEditor } from "@/components/editor/TiptapEditor"; import { getCategoryConfig } from "@/lib/category"; -import { markdownToSafeHtml } from "@/lib/markdown-to-tiptap"; +import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap"; import { useExecutionContext } from "./ExecutionContext"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; @@ -15,6 +16,7 @@ export function TaskSlideOver() { const { selectedEntry, setSelectedTaskId } = useExecutionContext(); const queueTaskMutation = trpc.queueTask.useMutation(); const deleteTaskMutation = trpc.deleteTask.useMutation(); + const updateTaskMutation = trpc.updateTask.useMutation(); const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]); @@ -28,6 +30,46 @@ export function TaskSlideOver() { return () => document.removeEventListener("keydown", onKeyDown); }, [selectedEntry, close]); + // Debounced description save + const timerRef = useRef | null>(null); + const pendingRef = useRef<{ id: string; description: string } | null>(null); + + const flushDescription = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current) { + const data = pendingRef.current; + pendingRef.current = null; + updateTaskMutation.mutate(data); + } + }, [updateTaskMutation]); + + const handleDescriptionUpdate = useCallback( + (json: string) => { + const task = selectedEntry?.task; + if (!task) return; + pendingRef.current = { id: task.id, description: json }; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(flushDescription, 1000); + }, + [selectedEntry?.task, flushDescription], + ); + + // Flush on close / unmount + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + if (pendingRef.current) { + const data = pendingRef.current; + pendingRef.current = null; + updateTaskMutation.mutate(data); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const task = selectedEntry?.task ?? null; const dependencies = selectedEntry?.blockedBy ?? []; const dependents = selectedEntry?.dependents ?? []; @@ -36,10 +78,18 @@ export function TaskSlideOver() { dependencies.every((d) => d.status === "completed"); const canQueue = task !== null && task.status === "pending" && allDepsComplete; - const descriptionHtml = useMemo(() => { + // Convert existing markdown description to tiptap JSON for the editor. + // If it's already valid tiptap JSON, use as-is. + const editorContent = useMemo(() => { if (!task?.description) return null; try { - return markdownToSafeHtml(task.description); + const parsed = JSON.parse(task.description); + if (parsed?.type === "doc") return task.description; + } catch { + // Not JSON — treat as markdown + } + try { + return JSON.stringify(markdownToTiptapJson(task.description)); } catch { return null; } @@ -56,7 +106,10 @@ export function TaskSlideOver() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} - onClick={close} + onClick={() => { + flushDescription(); + close(); + }} /> {/* Panel */} @@ -77,9 +130,17 @@ export function TaskSlideOver() { {selectedEntry.phaseName}

+ {updateTaskMutation.isPending && ( + + Saving... + + )} @@ -108,18 +169,14 @@ export function TaskSlideOver() { - {/* Description */} + {/* Description — editable tiptap */}
- {descriptionHtml ? ( -
- ) : ( -

- No description -

- )} +
{/* Dependencies */} diff --git a/docs/server-api.md b/docs/server-api.md index d033b59..ff8e17b 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -73,6 +73,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | listTasks | query | Child tasks of parent | | getTask | query | Single task | | updateTaskStatus | mutation | Change task status | +| updateTask | mutation | Update task fields (name, description) | | createInitiativeTask | mutation | Create task on initiative | | createPhaseTask | mutation | Create task on phase | | listInitiativeTasks | query | All tasks for initiative |