task-order.ts6.3 KBView on GitHub
/**
 * How task cards order within a block — one comparator for the board's columns, the list's
 * sections and the execution sidebar, so the three surfaces cannot disagree about what order is.
 *
 * There is ONE order. It asks two things, in this order: who made the card, then `sortOrder`.
 * The toolbar's Ordering does not choose a comparator; it chooses how to RE-SEED that column
 * (`userTasks.restampSortOrder`). This is the whole design, and it is what lets a card be dragged
 * in any ordering — there is no "manual mode" to be in or out of, because the board is always
 * showing you an order you are allowed to change.
 *
 * The authorship band comes FIRST and is not negotiable by dragging: everything a person typed
 * sits above everything an agent generated. A list where your own three todos are buried under
 * forty agent follow-ups is a list you stop reading, and no per-card ordering fixes that because
 * the agent keeps adding cards. Within each band `sortOrder` governs exactly as before, so a drag
 * still moves a card and the database still places new ones by due date.
 *
 * The alternative, which shipped first and was wrong: three live comparators plus a fourth
 * `manual` mode that alone read `sortOrder`. Dragging outside that mode did nothing, silently,
 * because the drop had nowhere to write — and the mode lived behind a popover, so the common
 * experience of the feature was that dragging was broken.
 *
 * Liveness is not lost with it. `sortOrder` is maintained by the database: a new task is placed
 * between its due-date neighbours on insert, and an UNPINNED card is re-placed when its due date
 * changes (see user_tasks_sort_order.sql). So snoozing a card still moves it. What a drag does is
 * PIN the card, which is the row saying "a human put me here, stop maintaining me".
 */
/** The fields ordering reads. Whole task objects are accepted; nothing else is touched. */
export interface OrderableTask {
  id?: string;
  sortOrder?: number;
  dueDate?: string | Date | null;
  createdAt?: string | Date | null;
  taskCreatedBy?: 'agent' | 'user' | null;
}

/**
 * A task with no date sorts as epoch 0 — the far past.
 *
 * That is deliberate under `due-desc`, the default: an undated task is work with nothing
 * scheduling it away, so it belongs at the "most overdue" end rather than jumping the queue.
 */
function time(value?: string | Date | null): number {
  return value ? new Date(value).getTime() : 0;
}

/**
 * `sortOrder` is NOT NULL server-side, but a task can reach a comparator before the server has
 * ever seen it — the optimistic row `use-create-task-optimistic` writes into the slice. Treat a
 * missing value as 0 rather than NaN, which would make the sort non-deterministic.
 */
function order(task: OrderableTask): number {
  return task.sortOrder ?? 0;
}

/**
 * Two cards can legitimately share a `sortOrder`: placement computes a midpoint from the
 * neighbours it reads, so a burst of agent-created tasks inserting in the same instant can land
 * on the same number. A collision must be *harmless*, not silently order-dependent — so the
 * comparator falls back to the newest due date, and then to id, rather than leaving tied cards in
 * whatever order the wire happened to deliver.
 *
 * The database-side twin of this tiebreak is in `projectBoard` (the CLI's board projection) and in
 * `restampSortOrder`'s SQL. All three must agree or the surfaces disagree about the board.
 */
function tiebreak(a: OrderableTask, b: OrderableTask): number {
  const byDue = time(b.dueDate) - time(a.dueDate);
  if (byDue !== 0) return byDue;
  return (a.id ?? '').localeCompare(b.id ?? '');
}

/**
 * Which band a card is in: 0 for what a person typed, 1 for everything else.
 *
 * `null` is the legacy value — rows written before the column existed, whose author nobody
 * recorded — and it ranks with the agent deliberately. The top band is a promise that those cards
 * are yours; an unknown is not evidence that it is, and quietly promoting thousands of old rows
 * into it would empty the promise of meaning on exactly the accounts with the most history.
 *
 * The SQL twin is in `restampSortOrder` (`task_created_by IS NOT DISTINCT FROM 'user'`, which is
 * false rather than null for an unrecorded author) and in `projectBoard`. All three must agree.
 */
function authorRank(task: OrderableTask): number {
  return task.taskCreatedBy === 'user' ? 0 : 1;
}

/**
 * The board's order: authorship band first, then `sortOrder` (smaller sorts nearer the top).
 *
 * Takes no argument on purpose: there is no mode to pass. Every surface that renders tasks calls
 * this, and the Ordering control changes the DATA rather than the comparator.
 *
 * A drag across the band boundary is resolved by the band, not the drop. `sortOrderForDrop` will
 * happily write a midpoint that sits among agent cards, and the card then renders at the BOTTOM of
 * its own band rather than where it was released. That is the correct outcome of the two rules
 * combined — the band is an invariant, the drop is a preference — and it is why a drop needs no
 * special case here.
 */
export function compareTasks(): (a: OrderableTask, b: OrderableTask) => number {
  return (a, b) => authorRank(a) - authorRank(b) || order(a) - order(b) || tiebreak(a, b);
}

/**
 * The `sortOrder` a dragged card should carry, given the column as it looks AFTER the drop and
 * the index the card landed at.
 *
 * Midpoint of its two new neighbours, so a drop writes exactly one row rather than renumbering
 * the column. At either end there is only one neighbour, so step past it by 1 — the scale is
 * arbitrary, only the ordering matters.
 *
 * Pass the column with the dragged card already moved into place (i.e. the result of dnd-kit's
 * `arrayMove`); `index` is where it now sits.
 */
export function sortOrderForDrop(column: readonly OrderableTask[], index: number): number {
  const above = index > 0 ? column[index - 1] : undefined;
  const below = index < column.length - 1 ? column[index + 1] : undefined;

  if (above && below) return (order(above) + order(below)) / 2;
  if (below) return order(below) - 1;
  if (above) return order(above) + 1;
  // Dropped into an empty column — it is the only card, so any value will do.
  return 0;
}

/** The minimum a board column exposes to drop resolution. */
export interface DroppableColumn<T extends OrderableTask & { id: string }> {
  key=[redacted];
  droppableId: string;
  tasks: T[];
}