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[];
}