From ddba8813800a1356e1d9df2062e8d13702d8dc52 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 21 Sep 2026 12:48:35 -0600 Subject: [PATCH] Replace notes dialog with group session dialog - Combine group notes and to-dos in one editing surface - Add debounced autosave with explicit save and flush-on-close - Preserve in-progress local edits against cross-device and AI updates --- components/board/group-card.tsx | 16 +- components/board/group-session-dialog.tsx | 481 ++++++++++++++++++++++ components/board/notes-dialog.tsx | 112 ----- hooks/use-autosave.ts | 64 +++ 4 files changed, 559 insertions(+), 114 deletions(-) create mode 100644 components/board/group-session-dialog.tsx delete mode 100644 components/board/notes-dialog.tsx create mode 100644 hooks/use-autosave.ts diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index ab405c3..ea367c7 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -9,6 +9,7 @@ import { ClipboardList, GripVertical, MoreVertical, + NotebookPen, Pencil, Plus, Sparkles, @@ -33,7 +34,7 @@ import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme"; import { useBoard } from "@/components/board/board-context"; import { useBoardView } from "@/components/board/board-view-provider"; import { NEW_GROUP_HOLD_MS, useHoldOnComplete } from "@/components/hold-on-complete"; -import { NotesDialog } from "@/components/board/notes-dialog"; +import { GroupSessionDialog } from "@/components/board/group-session-dialog"; import { TodoAiDialog } from "@/components/board/todo-ai-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { TodoCreatePopover } from "@/components/board/todo-create-popover"; @@ -97,6 +98,7 @@ export function GroupCard({ group }: { group: GroupDTO }) { const [editingTodo, setEditingTodo] = useState(null); const [editOpen, setEditOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); + const [sessionOpen, setSessionOpen] = useState(false); const [todoAiOpen, setTodoAiOpen] = useState(false); const [todoCreateOpen, setTodoCreateOpen] = useState(false); const [statusUpdateOpen, setStatusUpdateOpen] = useState(false); @@ -270,7 +272,15 @@ export function GroupCard({ group }: { group: GroupDTO }) {
- + + + ({ + 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. +

+ )} +
+ ); +} diff --git a/components/board/notes-dialog.tsx b/components/board/notes-dialog.tsx deleted file mode 100644 index a95ffad..0000000 --- a/components/board/notes-dialog.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { NotebookPen, Pencil, Sparkles } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { colorModeFromTheme } from "@/components/theme/use-dark-theme"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { MDEditor, MarkdownPreview } from "@/components/markdown/markdown-widgets"; -import { useBoard } from "@/components/board/board-context"; -import { GroupAiDialog } from "@/components/board/group-ai-dialog"; -import type { GroupDTO } from "@/types/board"; - -export function NotesDialog({ group }: { group: GroupDTO }) { - const { saveNote } = useBoard(); - const colorMode = colorModeFromTheme(); - - const [open, setOpen] = useState(false); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(group.noteContent); - const [pending, setPending] = useState(false); - const [aiOpen, setAiOpen] = useState(false); - - function handleOpenChange(next: boolean) { - setOpen(next); - if (next) { - setDraft(group.noteContent); - setEditing(false); - } - } - - // The "Ask AI" dialog can update this group's notes (via saveNote) while - // this dialog stays open behind it. Pick up that change so the accepted - // AI notes show up here immediately instead of the stale pre-AI draft. - useEffect(() => { - if (open && !editing) { - setDraft(group.noteContent); - } - }, [group.noteContent, open, editing]); - - async function handleSave() { - setPending(true); - const ok = await saveNote(group.id, group.categoryId, draft); - setPending(false); - if (ok) setEditing(false); - } - - return ( - <> - - - - - {group.title} — Notes - - - {editing ? ( - setDraft(v ?? "")} height={360} /> - ) : draft ? ( -
- -
- ) : ( -

- No notes yet. Click Edit to add some. -

- )} - - - {editing ? ( - <> - - - - ) : ( - <> - - - - )} - -
-
- - - - ); -} diff --git a/hooks/use-autosave.ts b/hooks/use-autosave.ts new file mode 100644 index 0000000..8d95337 --- /dev/null +++ b/hooks/use-autosave.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// "A few seconds" per the group session's live-editing requirement -- +// short enough to feel live, long enough not to fire a save on every +// keystroke. Deliberately well under board-context's 30s cross-device +// poll interval, so a local edit always settles before the poll could +// otherwise consider overwriting it. +export const AUTOSAVE_DELAY_MS = 2500; + +/** + * Debounced autosave for a single field's value. Call `onSave` a beat + * after `value` stops changing, and again immediately via `flush()` (used + * by a manual "Save" button and by flush-on-close) -- both paths funnel + * through the same `runSave`, so there's no separate "save now" logic to + * keep in sync with the debounced one. + * + * `onSave` must be one of the board-context mutators (e.g. `saveNote`, + * `editTodo`), never a raw server action directly -- those mutators already + * mark the write as in-flight (see `track()` in board-context.tsx) so the + * app's cross-device poll doesn't clobber it mid-save. + */ +export function useAutosave( + value: T, + onSave: (value: T) => Promise, + delayMs = AUTOSAVE_DELAY_MS +): { flush: () => Promise; isDirty: boolean; isSaving: boolean } { + const [savedValue, setSavedValue] = useState(value); + const [isSaving, setIsSaving] = useState(false); + const onSaveRef = useRef(onSave); + const savingPromiseRef = useRef | null>(null); + + useEffect(() => { + onSaveRef.current = onSave; + }, [onSave]); + + const runSave = useCallback((v: T) => { + setIsSaving(true); + const promise = (async () => { + await onSaveRef.current(v); + setSavedValue(v); + setIsSaving(false); + })(); + savingPromiseRef.current = promise; + return promise; + }, []); + + useEffect(() => { + if (value === savedValue) return; + const timer = setTimeout(() => { + void runSave(value); + }, delayMs); + return () => clearTimeout(timer); + }, [value, savedValue, delayMs, runSave]); + + const flush = useCallback(async () => { + if (value !== savedValue) { + await runSave(value); + } else if (savingPromiseRef.current) { + await savingPromiseRef.current; + } + }, [value, savedValue, runSave]); + + return { flush, isDirty: value !== savedValue, isSaving }; +}