"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTheme } from "next-themes";
import {
Archive,
ChevronRight,
ClipboardList,
GripVertical,
MoreVertical,
Pencil,
Plus,
Sparkles,
StickyNote,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { TodoCheckbox } from "@/components/board/todo-checkbox";
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete";
import { NotesDialog } from "@/components/board/notes-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";
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
import { EditGroupDialog } from "@/components/board/edit-group-dialog";
import { StatusUpdateDialog } from "@/components/board/status-update-dialog";
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
import type { GroupDTO, TodoDTO } from "@/types/board";
// Compact's "+" -- sits inline on the last visible to-do row (its sibling
// text button is flex-1, so this shrink-0 trigger naturally lands at the
// row's right edge) instead of on a row of its own.
function AddTodoMenu({
group,
aiConfigured,
onAddClick,
onAiClick,
}: {
group: GroupDTO;
aiConfigured: boolean;
onAddClick: () => void;
onAiClick: () => void;
}) {
return (
}
/>
Add to-do
{aiConfigured && (
Add using AI
)}
);
}
export function GroupCard({ group }: { group: GroupDTO }) {
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
const { view } = useBoardView();
const { holds, beginHold, cancelHold } = useHoldOnComplete();
const compact = view === "compact";
const { resolvedTheme } = useTheme();
const [editingTodo, setEditingTodo] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [todoAiOpen, setTodoAiOpen] = useState(false);
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({
id: group.id,
data: { type: "group", categoryId: group.categoryId },
});
// Another card is being dragged and is currently hovering over this one
// -- signal "it'll land near here" with a ring, distinct from this
// card's own accent-colored border. (isDragging guards against this
// card ever ringing itself while it's the one being moved.)
const isDropTarget = isOver && !isDragging;
const color = getGroupColor(group.color);
const isDark = resolvedTheme === "dark";
const borderColor = isDark ? color.dark : color.light;
// A subdued tint of the same stroke color, blended into the theme's own
// card surface -- not a fixed pastel, so it automatically stays correct
// if the neutral card token ever changes, and needs no separate
// light/dark background palette to hand-tune.
const backgroundColor = isDark
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
// Opposing hue from the group's own color, for the progress pie below --
// so it reads as a distinct accent rather than blending into the border.
const pieAccentColor = getComplementaryColor(
isDark ? color.dark : color.light,
isDark ? "dark" : "light"
);
// Un-checked to-do text: a brighter tint of the group's own color rather
// than the default (near-white in dark mode) foreground, so the group
// title reads as the most prominent thing on the card.
const todoTextColor = getBrighterColor(borderColor, isDark ? "dark" : "light");
// Only offer archiving once there's actually something done -- a group
// with no to-dos yet, or with any still open, isn't "finished" yet.
const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
const completedCount = group.todos.filter((t) => t.completed).length;
// Compact surfaces open work, plus anything still mid-hold after being
// freshly checked off (see useCompactHold) -- it stays on screen,
// crossed out, through its fade before actually dropping out of the list.
const visibleTodos = compact
? group.todos.filter((t) => !t.completed || holds.has(t.id))
: group.todos;
// The "+" rides on the last still-open row (see isLastRow below), not
// necessarily the last row overall -- a held to-do can be lingering at
// the tail while it fades out. -1 when every visible row is mid-hold,
// which the fallback block below covers.
let lastOpenTodoIndex = -1;
if (compact) {
visibleTodos.forEach((t, i) => {
if (!t.completed) lastOpenTodoIndex = i;
});
}
// In the default view, a fully-checked-off group collapses down to just
// its header (title, notes icon, kebab menu) with a disclosure triangle
// in place of the drag-grip dots, so a board full of "done" groups
// doesn't stay as visually loud as one still full of open work. The
// triangle re-expands it back to the normal full card. Compact already
// hides finished groups outright (see CategoryLane), so this only
// applies to the default view.
const showDisclosure = !compact && canArchive;
// Reset back to collapsed each time the group freshly becomes fully
// done, rather than remembering a stale expanded choice from its last
// completion cycle. Adjusting state during render (comparing against
// last render's value) instead of in a useEffect avoids an extra render
// pass -- see https://react.dev/learn/you-might-not-need-an-effect.
const [wasComplete, setWasComplete] = useState(canArchive);
if (canArchive !== wasComplete) {
setWasComplete(canArchive);
if (canArchive) setExpandedWhileComplete(false);
}
const isCollapsed = showDisclosure && !expandedWhileComplete;
return (
<>
{showDisclosure ? (
) : (
)}
{group.title}
}
/>
setEditOpen(true)}>
Edit
{aiConfigured && (
setStatusUpdateOpen(true)}>
Status Update
)}
setConfirmOpen(true)}>
Delete group
{!isCollapsed && (
<>
{visibleTodos.map((todo, index) => {
const todoButton = (
);
// In compact, the "+" rides along on the last *open* row
// instead of taking a row of its own -- a to-do mid-hold
// (see useCompactHold) can be lingering after it, fading
// out, so this isn't simply the last row overall.
const isLastRow = compact && index === lastOpenTodoIndex;
// Held to-dos are the only completed ones compact ever
// keeps mounted (see visibleTodos above); everywhere else
// this is undefined and the row renders at rest, exactly
// as before.
const holdPhase = compact ? holds.get(todo.id) : undefined;
return (
{
toggleTodoDone(todo.id, group.id, group.categoryId, checked);
if (!compact) return;
// Checking off holds it on screen for a grace
// period; unchecking -- including as a
// change-of-mind mid-hold -- cancels that hold
// outright so it snaps back to a normal open row.
if (checked) beginHold(todo.id);
else cancelHold(todo.id);
}}
accentColor={borderColor}
isDark={isDark}
className="mt-0.5"
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
/>
{todo.details ? (
{todoButton}
) : (
todoButton
)}
{isLastRow && (
setTodoCreateOpen(true)}
onAiClick={() => setTodoAiOpen(true)}
/>
)}
);
})}
{compact ? (
// No open row to ride on -- either every to-do here is
// mid-hold after being freshly checked off (about to clear
// the group entirely once its fade finishes), or, as a
// defensive fallback that shouldn't otherwise happen,
// CategoryLane failed to hide an already-empty group.
lastOpenTodoIndex === -1 && (