"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Pencil, Plus, Sparkles, Trash2, X, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets"; import { colorModeFromTheme, isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme"; import { getBrighterColor } from "@/lib/colors"; import { useBoard } from "@/components/board/board-context"; import { TodoCheckbox } from "@/components/board/todo-checkbox"; import { GroupAiDialog } from "@/components/board/group-ai-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog"; import { useAutosave } from "@/hooks/use-autosave"; import type { GroupDTO, TodoDTO } from "@/types/board"; const TITLE_MAX = 20; type TodoDraft = { id: string; title: string; details: string; completed: boolean; isNew: boolean; order: number; }; function tempId(): string { return `temp-${crypto.randomUUID()}`; } function toDrafts(todos: TodoDTO[]): TodoDraft[] { return todos.map((t) => ({ id: t.id, title: t.title, details: t.details ?? "", completed: t.completed, isNew: false, order: t.order, })); } /** * Merges fresh server todos into the local draft list without clobbering * anything the user has mid-edit: a draft whose id is in `dirtyIds` (still * unsaved, or mid-save) is kept as-is; everything else is refreshed from * the server, including dropping drafts the server no longer has and * picking up server todos not yet represented locally. */ function reconcileTodoDrafts( current: TodoDraft[], serverTodos: TodoDTO[], dirtyIds: Set ): TodoDraft[] { const currentById = new Map(current.map((d) => [d.id, d])); const merged: TodoDraft[] = []; for (const todo of serverTodos) { const existing = currentById.get(todo.id); if (existing && dirtyIds.has(todo.id)) { merged.push(existing); } else { merged.push({ id: todo.id, title: todo.title, details: todo.details ?? "", completed: todo.completed, isNew: false, order: todo.order, }); } } // Local-only new rows (not yet persisted) and any dirty draft the server // list somehow doesn't have yet both survive untouched. for (const draft of current) { if (draft.isNew || (dirtyIds.has(draft.id) && !serverTodos.some((t) => t.id === draft.id))) { if (!merged.some((d) => d.id === draft.id)) merged.push(draft); } } return merged; } export function GroupSessionDialog({ group, open, onOpenChange, }: { group: GroupDTO; open: boolean; onOpenChange: (open: boolean) => void; }) { const { saveNote, addTodo, editTodo, toggleTodoDone, removeTodo } = useBoard(); const colorMode = colorModeFromTheme(); const isDark = isDarkTheme(); const color = useGroupColor(group.color); const accentColor = isDark ? color.dark : color.light; const todoTextColor = getBrighterColor(accentColor, isDark ? "dark" : "light"); const [noteDraft, setNoteDraft] = useState(group.noteContent); const [todoDrafts, setTodoDrafts] = useState(() => toDrafts(group.todos)); const [aiOpen, setAiOpen] = useState(false); const [todoAiOpen, setTodoAiOpen] = useState(false); const [savingAll, setSavingAll] = useState(false); const rowFlushersRef = useRef(new Map Promise>()); const dirtyRowIdsRef = useRef(new Set()); const noteAutosave = useAutosave( noteDraft, useCallback((v: string) => saveNote(group.id, group.categoryId, v), [group.id, group.categoryId, saveNote]) ); // Seed fresh drafts every time the session opens, so a prior open's // leftover local-only "new" rows never leak into the next session. function handleOpenChange(next: boolean) { if (next) { setNoteDraft(group.noteContent); setTodoDrafts(toDrafts(group.todos)); } else { // Fire-and-forget: close immediately, flush any pending autosaves in // the background. board-context's mutators already toast on failure, // and their state lives above this dialog so it's safe to keep // running after this component unmounts. void handleSaveAll(); } onOpenChange(next); } // Picks up remote changes (cross-device poll, an accepted "Ask AI" edit) // while this session is open, but never while the corresponding field is // dirty or mid-save -- otherwise a poll landing mid-keystroke would wipe // out what the user just typed. useEffect(() => { if (!open) return; if (!noteAutosave.isDirty && !noteAutosave.isSaving) { // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing from an external source (cross-device poll / accepted AI edit), same pattern as theme-provider.tsx setNoteDraft(group.noteContent); } }, [group.noteContent, open, noteAutosave.isDirty, noteAutosave.isSaving]); useEffect(() => { if (!open) return; setTodoDrafts((prev) => reconcileTodoDrafts(prev, group.todos, dirtyRowIdsRef.current)); }, [group.todos, open]); function updateTodoDraft(id: string, patch: Partial) { setTodoDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))); } function removeTodoDraftLocalOnly(id: string) { setTodoDrafts((prev) => prev.filter((d) => d.id !== id)); dirtyRowIdsRef.current.delete(id); rowFlushersRef.current.delete(id); } function registerRowFlush(id: string, flush: () => Promise) { rowFlushersRef.current.set(id, flush); return () => { rowFlushersRef.current.delete(id); }; } function setRowDirty(id: string, dirty: boolean) { if (dirty) dirtyRowIdsRef.current.add(id); else dirtyRowIdsRef.current.delete(id); } async function handleAddTodoCreated(tempIdToRemove: string) { removeTodoDraftLocalOnly(tempIdToRemove); } function addEmptyTodoDraft() { setTodoDrafts((prev) => [ ...prev, { id: tempId(), title: "", details: "", completed: false, isNew: true, order: prev.length }, ]); } async function handleSaveAll() { setSavingAll(true); try { await Promise.all([ noteAutosave.flush(), ...Array.from(rowFlushersRef.current.values()).map((f) => f()), ]); } finally { setSavingAll(false); } } return ( <>
{group.title}
{savingAll || noteAutosave.isSaving ? "Saving…" : "Saved"} } >
setNoteDraft(v ?? "")} height={280} preview="edit" />
{todoDrafts.map((draft) => ( updateTodoDraft(draft.id, patch)} onRemoveLocalOnly={() => removeTodoDraftLocalOnly(draft.id)} onCreated={() => handleAddTodoCreated(draft.id)} onDirtyChange={(dirty) => setRowDirty(draft.id, dirty)} registerFlush={registerRowFlush} addTodo={addTodo} editTodo={editTodo} toggleTodoDone={toggleTodoDone} removeTodo={removeTodo} /> ))} {todoDrafts.length === 0 && (

No to-dos yet. Add one below.

)}
); } function SessionTodoRow({ draft, groupId, categoryId, accentColor, todoTextColor, isDark, onChangeLocal, onRemoveLocalOnly, onCreated, onDirtyChange, registerFlush, addTodo, editTodo, toggleTodoDone, removeTodo, }: { draft: TodoDraft; groupId: string; categoryId: string; accentColor: string; todoTextColor: string; isDark: boolean; onChangeLocal: (patch: Partial) => void; onRemoveLocalOnly: () => void; onCreated: () => void; onDirtyChange: (dirty: boolean) => void; registerFlush: (id: string, flush: () => Promise) => () => void; addTodo: ReturnType["addTodo"]; editTodo: ReturnType["editTodo"]; toggleTodoDone: ReturnType["toggleTodoDone"]; removeTodo: ReturnType["removeTodo"]; }) { const [editingDetails, setEditingDetails] = useState(false); // Guards against creating the same new to-do twice if title/details both // become dirty in the same tick (title autosave and details autosave // would otherwise both try to create it). const creatingRef = useRef | null>(null); const createIfNeeded = useCallback(async (): Promise => { if (!draft.isNew) return; const title = draft.title.trim(); if (!title) return; if (creatingRef.current) { await creatingRef.current; return; } creatingRef.current = (async () => { const ok = await addTodo(groupId, categoryId, title, draft.details.trim() || undefined); if (ok) onCreated(); })(); await creatingRef.current; }, [draft.isNew, draft.title, draft.details, groupId, categoryId, addTodo, onCreated]); const saveTitle = useCallback( async (title: string) => { if (draft.isNew) { await createIfNeeded(); return; } if (!title.trim()) return; await editTodo(draft.id, groupId, categoryId, { title: title.trim() }); }, [draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded] ); const saveDetails = useCallback( async (details: string) => { if (draft.isNew) { await createIfNeeded(); return; } await editTodo(draft.id, groupId, categoryId, { details: details.trim() || null }); }, [draft.isNew, draft.id, groupId, categoryId, editTodo, createIfNeeded] ); const titleAutosave = useAutosave(draft.title, saveTitle); const detailsAutosave = useAutosave(draft.details, saveDetails); const dirty = titleAutosave.isDirty || titleAutosave.isSaving || detailsAutosave.isDirty || detailsAutosave.isSaving; useEffect(() => { onDirtyChange(dirty); }, [dirty, onDirtyChange]); useEffect(() => { const flush = async () => { if (draft.isNew && draft.title.trim()) { await createIfNeeded(); return; } await Promise.all([titleAutosave.flush(), detailsAutosave.flush()]); }; return registerFlush(draft.id, flush); }, [draft.id, draft.isNew, draft.title, createIfNeeded, titleAutosave, detailsAutosave, registerFlush]); function handleToggle(next: boolean) { onChangeLocal({ completed: next }); if (!draft.isNew) { void toggleTodoDone(draft.id, groupId, categoryId, next); } } function handleDelete() { if (draft.isNew) { onRemoveLocalOnly(); } else { onRemoveLocalOnly(); void removeTodo(draft.id, groupId, categoryId); } } const checkbox = ( ); return (
{draft.isNew ? ( } > {checkbox} Save a title first ) : ( checkbox )} onChangeLocal({ title: e.target.value })} style={draft.completed ? undefined : { color: todoTextColor }} className={cn("flex-1", draft.completed && "text-muted-foreground line-through")} />
{editingDetails ? ( onChangeLocal({ details: v ?? "" })} height={160} preview="edit" /> ) : draft.details ? (
) : (

No details yet.

)}
); }