task-drop-plan.ts8.5 KBView on GitHub /**
* What a board drop MEANS, decided before anything is written.
*
* A single drag gesture on the task board can mean four different things depending on where it
* started, where it landed, and which ordering mode the board is in: reorder within a lane, re-file
* into another group, reschedule into Upcoming, or pull back out of Upcoming into today's work.
* Deciding that inside the drag handler put the whole matrix behind a real mouse — this module is
* the decision on its own, so every branch is reachable from a test.
*
* See TASK_REORDERING_DESIGN.md.
*/
import {
sortOrderForDrop,
type DroppableColumn,
type OrderableTask,
} from '@/modules/userTasks/utils/task-order';
import type { TaskColumnBy } from '@/modules/userTasks/hooks/use-task-list-view-options';
/**
* The id a card registers with dnd-kit under.
*
* A real card registers under its task id, so dnd-kit can start a drag from it and the board can
* find its DOM node again. The `DragOverlay` copy must NOT: it is a second mount of the same
* component for the card currently being dragged, and hooks cannot be skipped, so it calls
* `useSortable` too.
*
* dnd-kit keys its registry by id. The overlay never receives a node (it is rendered with
* `draggable={false}`, so `setNodeRef` is never called), so registering it under the task id
* clobbers the ACTIVE card's entry with a null rect — `verticalListSortingStrategy` can then
* compute no displacement and NOTHING moves out of the way. The drag looks completely inert while
* every other test still passes, which is exactly how this shipped.
*
* The suffix is not a valid task id, so a stray overlay id can never resolve to a real card.
*/
export function sortableCardId(taskId: string, isOverlay: boolean): string {
return isOverlay ? `${taskId}__overlay` : taskId;
}
/**
* Which slot a pointer at `y` falls into, given the vertical midpoints of a lane's cards.
*
* The index is the number of midpoints the pointer has passed, so it changes exactly once per
* boundary crossed — monotonic in `y`, and a pure function of it.
*
* That purity is the whole point, and it is what the previous approach lacked. Deriving the slot
* from whichever card dnd-kit reported under the cursor meant the card that had just moved out of
* the way changed the answer: insert at N, the gap opens, the cursor is now over a different card,
* the answer becomes N±1, the gap moves back, and the cursor is over the first card again. The
* pointer sits still while the gap flickers between two slots — "the hit box moves up and down".
*
* The caller snapshots `mids` when the pointer ENTERS a lane, before any gap has opened, and holds
* them for as long as it stays there. Fixed boundaries cannot be moved by the thing they position,
* so the oscillation has nowhere to come from.
*/
export function insertionIndexForY(mids: readonly number[], y: number): number {
let index = 0;
for (const mid of mids) {
if (y > mid) index++;
else break;
}
return index;
}
/** The virtual lane for tasks belonging to no group. Not a `task_groups` row. */
export const MISC_KEY=[redacted];
/** The single lane for everything due after today, whatever its group. */
export const UPCOMING_KEY=[redacted];
/** A card, as far as drop planning is concerned. */
export interface DroppableTask extends OrderableTask {
id: string;
taskGroupId?: string | null;
}
export type TaskDropPlan =
/** The drag changed nothing — dropped outside the board, or in a mode that can't express it. */
| { kind: 'none' }
/**
* Dropped onto Upcoming from another lane. "Upcoming" means "due later", and there is no later
* until the user says which — so this writes NOTHING and asks for a date.
*
* There is no rollback if they dismiss the picker: which column a card sits in is derived from
* its `dueDate`, and `dueDate` never changed, so the card never actually moved.
*/
| { kind: 'ask-upcoming'; taskId: string }
/** A real write: some combination of position, lane, and pulling a card back into the present. */
| {
kind: 'move';
taskId: string;
/** The group to file into — unchanged from the task's current one on a pure reorder. */
groupId: string | null;
/**
* Where the card now sits — the midpoint of its two new neighbours. Present only on a
* modified drop; without it the card is filed and left to the board's ordering.
*/
sortOrder?: number;
/** Present only when dragging OUT of Upcoming: the card is wanted now. */
dueDate?: Date;
/** Whether the lane actually changed — gates the group-cache invalidation and the relabel. */
refiles: boolean;
};
export interface TaskDropInput<T extends DroppableTask> {
/** The board's columns AS RENDERED — the dragged card already sits where it is being shown. */
columns: readonly DroppableColumn<T>[];
taskId: string;
/**
* The lane the pointer is in, resolved from its position against the lane rects.
*
* Deliberately not dnd-kit's `over`. Once the dragged card is displayed inside another lane,
* collision detection can resolve to the dragged card ITSELF, and looking that id up in the
* pre-drag columns answers with the lane it came from — so the lane flipped back and forth
* between origin and destination, from anywhere on the board, because it was the card's own id
* rather than anything about proximity.
*/
targetColumnKey=[redacted] | null;
/**
* The lane the card was picked up from, captured at drag start.
*
* Load-bearing: by the time this runs, `columns` shows the card in its DESTINATION, so asking
* where it currently lives would report the destination and every re-file would be skipped.
*/
originColumnKey?: string;
columnBy: TaskColumnBy;
/**
* The drop should PLACE the card, not just file it into the lane.
*
* True for any drag WITHIN a lane — there is nothing else such a drag could mean. Across lanes
* it is false unless the modifier is held: the default is to move the card and let the board's
* ordering decide where it lands. Placing across lanes PINS the row and stops the database
* maintaining its position, which is too consequential to be what happens when you nudge a card
* sideways.
*/
reorder?: boolean;
/** Wall clock, injected so "now" is assertable. */
now?: Date;
}
export function planTaskDrop<T extends DroppableTask>({
columns,
taskId,
targetColumnKey,
originColumnKey,
columnBy,
reorder = false,
now,
}: TaskDropInput<T>): TaskDropPlan {
if (!targetColumnKey) return { kind: 'none' };
const target = columns.find((c) => c.key === targetColumnKey);
if (!target) return { kind: 'none' };
const task = columns.flatMap((c) => c.tasks).find((t) => t.id === taskId);
if (!task) return { kind: 'none' };
const origin = originColumnKey ?? columns.find((c) => c.tasks.some((t) => t.id === taskId))?.key;
const sameColumn = origin === target.key;
// Dropped on Upcoming from elsewhere → a RESCHEDULE, and rescheduling needs a date. Open the
// picker and write nothing. If it is dismissed there is no rollback to perform: the card's lane
// is derived from `dueDate`, which never changed, so it never actually moved.
if (target.key === UPCOMING_KEY && !sameColumn) return { kind: 'ask-upcoming', taskId };
// The position is read back off the arrangement the user is looking at, rather than recomputed —
// whatever the board is showing IS the answer, so the two cannot disagree.
const placedIndex = target.tasks.findIndex((t) => t.id === taskId);
const sortOrder =
reorder && placedIndex !== -1 ? sortOrderForDrop(target.tasks, placedIndex) : undefined;
// Re-filing is a group-mode idea. Dropping into "Overdue" or "Slack" doesn't move a task between
// groups — those lanes describe a task rather than holding it.
const refiles = columnBy === 'group' && !sameColumn && target.key !== UPCOMING_KEY;
const groupId = refiles
? target.key === MISC_KEY
? null
: target.key=[redacted] ?? null);
// Dragging out of Upcoming means "do this now". Without a due date in the present the card
// re-derives straight back into Upcoming and the drag reads as broken.
const dueDate = origin === UPCOMING_KEY && refiles ? (now ?? new Date()) : undefined;
// Nothing asked for: an unmodified drop back into the lane the card already lives in.
if (sortOrder === undefined && !refiles && !dueDate) return { kind: 'none' };
return {
kind: 'move',
taskId,
groupId,
refiles,
...(sortOrder !== undefined ? { sortOrder } : {}),
...(dueDate ? { dueDate } : {}),
};
}