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 } : {}),
  };
}