How Calendar Drag-and-Drop Scheduling Works in lifetrace: A Deep Dive into the @dnd-kit Implementation
The calendar drag-and-drop scheduling in lifetrace uses a type-safe dispatch system built on @dnd-kit/core, where draggable Todo cards and droppable date cells communicate through a global context that triggers optimistic React Query cache updates via registered handler functions.
The lifetrace repository implements a flexible scheduling interface that allows users to reschedule Todo items by dragging them onto calendar dates. At its core, the system combines type-safe TypeScript unions, a custom dispatch registry, and optimistic UI patterns to ensure immediate visual feedback with server synchronization. This article examines the internal mechanics of how a drag operation on a Todo card translates into a persisted schedule change.
The Architecture Overview
The calendar drag-and-drop scheduling system follows a seven-step pipeline that separates drag initiation, global state coordination, and business logic execution:
- Drag initiation:
DraggableTodocreates a typed payload and registers withuseDraggable - Target detection:
DayColumnexposes drop zones viauseDroppablewith date metadata - Global tracking:
GlobalDndProvidermonitors active drags and renders visual overlays - Drop handling: On release, the provider extracts source and target data, hiding the original card temporarily
- Dispatch:
dispatchDragDroproutes the action to a specific handler using a"SOURCE->TARGET"key - Execution:
handleTodoToCalendarDatecalculates new timestamps and updates the React Query cache optimistically - Resolution: The UI re-renders with the new schedule, or rolls back on API failure
This flow is implemented across six primary files in the free-todo-frontend directory, with strict type safety enforced through discriminated unions in lib/dnd/types.ts.
Type-Safe Drag and Drop Payloads
All interactions rely on strict TypeScript definitions that prevent mismatched drag-and-drop combinations. The system uses discriminated unions declared in free-todo-frontend/lib/dnd/types.ts:
export type DragSourceType = "TODO_CARD" | "FILE" | "USER" | "PANEL_HEADER";
export type DragData =
| { type: "TODO_CARD"; payload: { todo: Todo; depth?: number; sourcePanel?: string } }
| { type: "FILE"; payload: { file: TodoAttachment; sourceTodoId?: number } }
| { type: "USER"; payload: { userId: string; userName: string } }
| { type: "PANEL_HEADER"; payload: { position: "panelA" | "panelB" | "panelC" } };
export type DropTargetType = "CALENDAR_DATE" | "CALENDAR_TIMELINE_SLOT";
export type DropData =
| { type: "CALENDAR_DATE"; metadata: { dateKey: string; date: Date } }
| { type: "CALENDAR_TIMELINE_SLOT"; metadata: { dateKey: string; date: Date; minutes: number } };
These types enable the dispatch system to guarantee that only valid source-target pairs trigger business logic. When a user drags a Todo card onto a calendar date, the system creates a handler key "TODO_CARD->CALENDAR_DATE" that maps to a specific implementation function.
From Drag Start to Drop: The Component Layer
Draggable Todo Cards
The DraggableTodo component in free-todo-frontend/apps/calendar/components/DraggableTodo.tsx initiates the drag by constructing a payload and registering with @dnd-kit/core:
const dragData: DragData = useMemo(
() => ({
type: "TODO_CARD",
payload: { todo: calendarTodo.todo, sourcePanel: "calendar" },
}),
[calendarTodo.todo],
);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `calendar-${calendarTodo.todo.id}`,
data: dragData,
});
The ID prefix calendar- prevents collisions with Todo IDs used elsewhere in the application. While dragging, the component checks PendingUpdateContext to temporarily hide the original card using opacity-0, preventing visual duplication while the optimistic update is in flight.
Droppable Calendar Days
Drop targets are created by DayColumn in free-todo-frontend/apps/calendar/components/DayColumn.tsx:
const dropData: DropData = useMemo(
() => ({
type: "CALENDAR_DATE",
metadata: { dateKey, date: day.date },
}),
[dateKey, day.date],
);
const { isOver, setNodeRef } = useDroppable({
id: `day-${dateKey}`,
data: dropData,
});
This setup supplies the full Date object of the target cell to the drop handler, enabling precise time calculations when the Todo is scheduled.
Global Coordination via GlobalDndProvider
The GlobalDndProvider in free-todo-frontend/lib/dnd/context.tsx wraps the entire application and coordinates state transitions:
const handleDragStart = useCallback((event) => {
const data = event.active.data.current as DragData | undefined;
if (data) {
setActiveDrag({ id: event.active.id, data });
}
}, []);
const handleDragEnd = useCallback((event) => {
const { active, over } = event;
if (over) {
const dragData = active.data.current as DragData | undefined;
const dropData = over.data.current as DropData | undefined;
if (dragData?.type === "TODO_CARD") {
const todoId = dragData.payload.todo.id;
setPendingTodoId(todoId);
setTimeout(() => setPendingTodoId(null), 150);
}
dispatchDragDrop(dragData, dropData);
}
setActiveDrag(null);
}, []);
The provider manages PendingUpdateContext to hide the source card during the mutation, and invokes dispatchDragDrop with the extracted type-safe payloads. It also renders a GlobalDragOverlay that portals a preview component to document.body, preventing clipping issues with CSS transforms.
The Handler Registry and Dispatch System
The dispatch mechanism in free-todo-frontend/lib/dnd/handlers.ts uses a registry pattern to route operations:
export function dispatchDragDrop(dragData: DragData, dropData: DropData) {
const key = `${dragData.type}->${dropData.type}` as HandlerKey;
const handler = getHandler(key);
if (!handler) return { success: false, message: `No handler for ${key}` };
return handler(dragData, dropData);
}
Handlers are registered using string keys that match the type combination:
registerHandler("TODO_CARD->CALENDAR_DATE", handleTodoToCalendarDate);
This architecture allows extending the system with new drag-and-drop interactions without modifying the core dispatch logic.
Optimistic Updates and Time Preservation Logic
The handleTodoToCalendarDate function in handlers.ts contains the core scheduling logic. It preserves existing time-of-day preferences while updating the date component:
const handleTodoToCalendarDate: DragDropHandler = (dragData, dropData) => {
const { todo } = dragData.payload;
const { date } = dropData.metadata;
const existingStart = normalizeTodoDate(todo.startTime);
const baseStart = existingStart;
const newStart = baseStart ? applyDate(date, baseStart) : applyDate(date, new Date(0));
if (!baseStart) newStart.setHours(9, 0, 0, 0);
const existingEnd = normalizeTodoDate(todo.endTime);
const durationMs = existingStart && existingEnd ? existingEnd.getTime() - existingStart.getTime() : null;
const newEnd = existingEnd ? (durationMs ? new Date(newStart.getTime() + durationMs) : applyDate(date, existingEnd)) : null;
};
The implementation uses flushSync to force synchronous React renders before the API call:
const previousTodos = queryClient.getQueryData(queryKeys.todos.list());
flushSync(() => {
queryClient.setQueryData<TodoListResponse>(queryKeys.todos.list(), (oldData) => {
// Immutable update of the specific Todo's position
return updatedData;
});
});
void updateTodoApiTodosTodoIdPut(todo.id, {
...(newStartStr ? { start_time: newStartStr } : {}),
...(newEndStr ? { end_time: newEndStr } : {}),
})
.then(() => getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all }))
.catch(() => {
if (previousTodos) queryClient.setQueryData(queryKeys.todos.list(), previousTodos);
});
If the API call fails, the previous cache snapshot restores the original schedule immediately.
Implementing Custom Drag-and-Drop Extensions
To add new draggable sources, implement useDraggable with a typed payload:
import { useDraggable } from "@dnd-kit/core";
import type { DragData } from "@/lib/dnd/types";
function CustomDraggable({ todo }) {
const dragData: DragData = useMemo(() => ({
type: "TODO_CARD",
payload: { todo, sourcePanel: "custom-panel" },
}), [todo]);
const { setNodeRef, listeners, attributes } = useDraggable({
id: `custom-${todo.id}`,
data: dragData,
});
return <div ref={setNodeRef} {...listeners} {...attributes}>{todo.name}</div>;
}
Registering a new handler requires importing the registry function:
import { registerHandler } from "@/lib/dnd/handlers";
import type { DragData, DropData, DragDropResult } from "@/lib/dnd/types";
const handleTodoToCustomTarget: DragDropHandler = (drag, drop) => {
// Implementation logic
return { success: true };
};
registerHandler("TODO_CARD->CUSTOM_TARGET", handleTodoToCustomTarget);
Summary
- Type safety is enforced through discriminated unions in
lib/dnd/types.ts, ensuring only valid source-target combinations execute business logic. - Global coordination happens in
GlobalDndProvider, which manages drag state, pending updates, and overlay rendering viacreatePortal. - Dispatch routing uses string keys like
"TODO_CARD->CALENDAR_DATE"to map operations to specific handler functions in the registry. - Optimistic updates employ
flushSyncand React Query cache manipulation to render changes immediately, with automatic rollback on API failure. - Time preservation logic maintains the original time-of-day or duration when moving Todos between dates, defaulting to 09:00 for new schedules.
Frequently Asked Questions
How does lifetrace prevent duplicate Todo cards during drag operations?
The system uses PendingUpdateContext managed by GlobalDndProvider to track the ID of the Todo being moved. The DraggableTodo component subscribes to this context and applies opacity-0 to the original card while the optimistic update is active. A 150ms timeout ensures the card remains hidden until React Query completes the cache update and re-renders the Todo in its new position.
What happens if the backend API fails during a drag-and-drop schedule update?
The handleTodoToCalendarDate function stores the previous React Query cache state in previousTodos before applying the optimistic update. If updateTodoApiTodosTodoIdPut rejects, the catch block immediately restores the cached data using queryClient.setQueryData. This rollback happens without requiring a page refresh, maintaining UI consistency.
Why does the system use a handler registry instead of switch statements?
The handler registry in lib/dnd/handlers.ts decouples the drag-and-drop infrastructure from business logic. By registering handlers with string keys like "TODO_CARD->CALENDAR_DATE", the system supports extensibility—new source and target types can be added without modifying the core dispatch logic in GlobalDndProvider or the type definitions.
How are time-of-day preferences preserved when rescheduling Todos?
The scheduling logic checks for existing startTime and endTime values on the Todo. If present, it extracts the hours and minutes using applyDate to combine the new date with the existing time. If the Todo has both start and end times, it calculates the duration in milliseconds and applies that same duration to the new start time, ensuring relative timing remains consistent across date changes.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →