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
This commit is contained in:
Brian Fertig 2026-09-21 12:48:35 -06:00
parent 323589f281
commit ddba881380
4 changed files with 559 additions and 114 deletions

View File

@ -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<TodoDTO | null>(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 }) {
</h3>
</div>
<div className="flex shrink-0 items-center">
<NotesDialog group={group} />
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={`Notes for ${group.title}`}
onClick={() => setSessionOpen(true)}
>
<NotebookPen className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
@ -480,6 +490,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
<EditGroupDialog group={group} open={editOpen} onOpenChange={setEditOpen} />
<GroupSessionDialog group={group} open={sessionOpen} onOpenChange={setSessionOpen} />
<TodoAiDialog
group={group}
open={todoAiOpen}

View File

@ -0,0 +1,481 @@
"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<string>
): 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<TodoDraft[]>(() => toDrafts(group.todos));
const [aiOpen, setAiOpen] = useState(false);
const [todoAiOpen, setTodoAiOpen] = useState(false);
const [savingAll, setSavingAll] = useState(false);
const rowFlushersRef = useRef(new Map<string, () => Promise<void>>());
const dirtyRowIdsRef = useRef(new Set<string>());
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<TodoDraft>) {
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<void>) {
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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
data-color-mode={colorMode}
showCloseButton={false}
className="fixed top-[2vh] left-1/2 h-[96vh] max-h-[96vh] w-[calc(100%-1rem)] max-w-[calc(100%-1rem)] sm:max-w-5xl -translate-x-1/2 translate-y-0 flex flex-col gap-0 overflow-hidden p-0"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b p-4">
<DialogTitle className="min-w-0 truncate">{group.title}</DialogTitle>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{savingAll || noteAutosave.isSaving ? "Saving…" : "Saved"}
</span>
<DialogClose
render={<Button variant="ghost" size="icon-sm" aria-label="Close" />}
>
<XIcon className="size-4" />
</DialogClose>
</div>
</div>
<div className="flex-1 space-y-6 overflow-y-auto overscroll-contain p-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Notes</Label>
<MDEditor
value={noteDraft}
onChange={(v) => setNoteDraft(v ?? "")}
height={280}
preview="edit"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">To-dos</Label>
<div className="space-y-2">
{todoDrafts.map((draft) => (
<SessionTodoRow
key={draft.id}
draft={draft}
groupId={group.id}
categoryId={group.categoryId}
accentColor={accentColor}
todoTextColor={todoTextColor}
isDark={isDark}
onChangeLocal={(patch) => 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 && (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No to-dos yet. Add one below.
</p>
)}
</div>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t bg-muted/50 p-4">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" className="gap-2" onClick={addEmptyTodoDraft}>
<Plus className="size-4" />
Add To-Do
</Button>
<Button variant="outline" className="gap-2" onClick={() => setTodoAiOpen(true)}>
<Sparkles className="size-4" />
Add To-Dos with AI
</Button>
<Button variant="outline" className="gap-2" onClick={() => setAiOpen(true)}>
<Sparkles className="size-4" />
Ask AI
</Button>
</div>
<Button onClick={handleSaveAll} disabled={savingAll}>
{savingAll ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
</>
);
}
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<TodoDraft>) => void;
onRemoveLocalOnly: () => void;
onCreated: () => void;
onDirtyChange: (dirty: boolean) => void;
registerFlush: (id: string, flush: () => Promise<void>) => () => void;
addTodo: ReturnType<typeof useBoard>["addTodo"];
editTodo: ReturnType<typeof useBoard>["editTodo"];
toggleTodoDone: ReturnType<typeof useBoard>["toggleTodoDone"];
removeTodo: ReturnType<typeof useBoard>["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<Promise<void> | null>(null);
const createIfNeeded = useCallback(async (): Promise<void> => {
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 = (
<TodoCheckbox
checked={draft.completed}
onCheckedChange={handleToggle}
accentColor={accentColor}
isDark={isDark}
className="mt-1"
aria-label={`Mark "${draft.title || "new to-do"}" ${draft.completed ? "incomplete" : "complete"}`}
/>
);
return (
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-start gap-2">
{draft.isNew ? (
<Tooltip>
<TooltipTrigger
render={<span className="mt-1 inline-flex pointer-events-none opacity-50" />}
>
{checkbox}
</TooltipTrigger>
<TooltipContent side="top">Save a title first</TooltipContent>
</Tooltip>
) : (
checkbox
)}
<Input
value={draft.title}
maxLength={TITLE_MAX}
placeholder="To-do title"
autoFocus={draft.isNew && !draft.title}
onChange={(e) => onChangeLocal({ title: e.target.value })}
style={draft.completed ? undefined : { color: todoTextColor }}
className={cn("flex-1", draft.completed && "text-muted-foreground line-through")}
/>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleDelete}
aria-label={`Delete "${draft.title || "new to-do"}"`}
>
{draft.isNew ? <X className="size-4" /> : <Trash2 className="size-4" />}
</Button>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Details</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => setEditingDetails((v) => !v)}
>
<Pencil className="size-3" />
{editingDetails ? "Preview" : "Edit"}
</Button>
</div>
{editingDetails ? (
<MDEditor
value={draft.details}
onChange={(v) => onChangeLocal({ details: v ?? "" })}
height={160}
preview="edit"
/>
) : draft.details ? (
<div className="max-h-40 overflow-y-auto rounded-md border p-2">
<MarkdownPreview source={draft.details} />
</div>
) : (
<p className="rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground">
No details yet.
</p>
)}
</div>
);
}

View File

@ -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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={`Notes for ${group.title}`}
onClick={() => handleOpenChange(true)}
>
<NotebookPen className="size-4" />
</Button>
<DialogContent className="sm:max-w-2xl" data-color-mode={colorMode}>
<DialogHeader>
<DialogTitle>{group.title} — Notes</DialogTitle>
</DialogHeader>
{editing ? (
<MDEditor value={draft} onChange={(v) => setDraft(v ?? "")} height={360} />
) : draft ? (
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-4">
<MarkdownPreview source={draft} />
</div>
) : (
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
No notes yet. Click Edit to add some.
</p>
)}
<DialogFooter>
{editing ? (
<>
<Button variant="outline" onClick={() => setEditing(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={pending}>
{pending ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setAiOpen(true)} className="gap-2">
<Sparkles className="size-4" />
Ask AI
</Button>
<Button variant="outline" onClick={() => setEditing(true)} className="gap-2">
<Pencil className="size-4" />
Edit
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<GroupAiDialog group={group} open={aiOpen} onOpenChange={setAiOpen} />
</>
);
}

64
hooks/use-autosave.ts Normal file
View File

@ -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<T>(
value: T,
onSave: (value: T) => Promise<boolean | void>,
delayMs = AUTOSAVE_DELAY_MS
): { flush: () => Promise<void>; isDirty: boolean; isSaving: boolean } {
const [savedValue, setSavedValue] = useState(value);
const [isSaving, setIsSaving] = useState(false);
const onSaveRef = useRef(onSave);
const savingPromiseRef = useRef<Promise<void> | 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 };
}