TASK_REORDERING_DESIGN.md58.8 KBView on GitHub # Task Reordering — Design
## 1) Introduction — goal, present state, future state
We want the task board to hold the order you put it in: drag a card up and it stays there, and work that arrives later slots in where its due date says it belongs rather than landing wherever a recomputation happens to put it. Today no user-owned order exists anywhere — `TaskKanbanBoard` recomputes each column on every render by comparing due dates ([TaskKanbanBoard.tsx:327](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx)), the `orderBy` view option is read by the List view but ignored by the board, and the Upcoming column refuses drops outright because rescheduling was deemed out of scope. This design adds two columns to `user_tasks` — `sort_order` (where a card sits) and `sort_order_pinned` (whether you put it there by hand) — makes the board honour `orderBy` with a new `manual` mode, places newly created tasks by due date against *unpinned* cards only via a `BEFORE INSERT` trigger so none of the nine insert sites change, and turns Upcoming into a drop target that opens the existing date picker, where dismissing the dialog leaves the card untouched because column membership derives from `dueDate` and nothing was written.
## 2) Present state
### 2.1 Architecture diagram
```text
9 insert sites ──────────► user_tasks row
(createTaskTool, mail.ts, │ (no order column; due_date only)
user-tasks.ts ×3, │
agenda-to-tasks, tasks.ts ×2) │
▼
listUserTasks (findMany, ORDER BY due_date)
│
▼
useHydrateTasksSlice ──► userTasksSlice.tasks
│
▼
useTodoTasks()
│
TaskKanbanBoard `columns` useMemo
│
bucketize(keyOf) ──► per-column sort: dueTime(b) - dueTime(a)
│
▼
TaskKanbanColumn (useDroppable) + TaskKanbanCard (useDraggable)
│
onDragEnd ──► moveTaskToGroup({ taskId, groupId })
│
(returns early if over.id === UPCOMING_KEY)
```
### 2.2 Step-by-step walkthrough
1. **A task is created** — e.g. `createTaskTool` at [createTaskTool.ts:628](apps/server/src/mastra/tools/task/createTaskTool.ts), one of nine `insert(userTasks)` sites (also [user-tasks.ts:644,2617,2789](apps/server/src/trpc/routes/user-tasks.ts), [mail.ts:218](apps/server/src/trpc/routes/mail.ts), [agenda-to-tasks.ts:185](apps/server/src/services/document-saving/hooks/agenda-to-tasks.ts), [tasks.ts:151,1308](apps/server/src/services/user-tasks/tasks.ts)).
- `due_date` is `NOT NULL DEFAULT now()`, so a task with no explicit date is due immediately.
- Row after this step:
```json
{ "id": "t4", "user_id": "u1", "due_date": "2026-08-26T09:00:00Z",
"task_group_id": "g1", "status": "todo" }
```
2. **`listUserTasks` reads them** — `privateProcedure` at [user-tasks.ts:231](apps/server/src/trpc/routes/user-tasks.ts).
- Builds `conditions` (user, status, excludes `deleted`/`agent_deleted`/`recommended`), then at [user-tasks.ts:325](apps/server/src/trpc/routes/user-tasks.ts):
```ts
const orderBy = input.sortDueDate === 'asc' ? [asc(userTasks.dueDate)] : [desc(userTasks.dueDate)];
```
- `db.query.userTasks.findMany({ where, orderBy, limit })` — selects the whole row, so any new column flows through with no route change.
- Returns:
```json
{ "tasks": [
{ "id": "t1", "due_date": "2026-08-27T…", "task_group_id": "g1" },
{ "id": "t4", "due_date": "2026-08-26T…", "task_group_id": "g1" },
{ "id": "t2", "due_date": "2026-08-25T…", "task_group_id": "g1" }
] }
```
3. **The board hydrates the slice** — `useHydrateTasksSlice(tasksData?.tasks)` at [TaskKanbanBoard.tsx:301](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), calling `hydrateTodoTasks` at [userTasksSlice.ts:556](apps/mail/modules/userTasks/slice/userTasksSlice.ts).
- Authoritative: upserts each incoming task and **drops** any slice task with `status === 'todo'` absent from the list. Every caller must pass the full todo set.
- The whole task object is stored under `state.tasks[id]`, so a new column needs only a type declaration to survive hydration.
4. **The board renders solely from the slice** — `useTodoTasks()` at [TaskKanbanBoard.tsx:309](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx), then `makeTaskFilter` narrows by the toolbar facets ([task-filters.ts:110](apps/mail/modules/userTasks/utils/task-filters.ts)).
- `hideFuture` is deliberately not applied — future-due tasks belong in Upcoming, not dropped.
5. **Columns are bucketed and sorted** — the `columns` useMemo at [TaskKanbanBoard.tsx:318](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx).
- `columnBy === 'group'` (default) keys each task by `dueTime(t) > endOfToday() ? UPCOMING_KEY=[redacted] ?? MISC_KEY`.
- **The order is recomputed here every render** and is not user-controllable:
```ts
for (const bucket of map.values()) bucket.sort((a, b) => dueTime(b) - dueTime(a));
```
- Upcoming is appended with `canDrop: false` — "Dropping here would mean rescheduling, not re-grouping — out of scope for now."
- Column shape after this step:
```json
{ "key": "g1", "label": "Responses", "canDrop": true,
"tasks": [{ "id": "t1" }, { "id": "t4" }, { "id": "t2" }] }
```
6. **`orderBy` exists but the board ignores it** — `useTaskListViewOptions` at [use-task-list-view-options.ts:38](apps/mail/modules/userTasks/hooks/use-task-list-view-options.ts) returns `orderBy` (`'due-asc' | 'due-desc' | 'created-desc'`, default `due-desc`), and the board destructures only `columnBy` and the filters at [TaskKanbanBoard.tsx:306](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx). The toolbar renders the Ordering select only in the list branch ([TasksToolbar.tsx:287](apps/mail/modules/userTasks/components/TasksToolbar.tsx)).
7. **Columns are droppable, cards are draggable** — `useDroppable({ id, disabled: !canDrop })` at [TaskKanbanColumn.tsx:47](apps/mail/modules/userTasks/components/TaskKanbanColumn.tsx), and `useDraggable({ disabled: !draggable })` at [TaskKanbanCard.tsx:147](apps/mail/modules/userTasks/components/TaskKanbanCard.tsx).
- The card's `draggable` prop is passed as `draggable={col.canDrop}` ([TaskKanbanBoard.tsx:577](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx)), so **droppability and draggability are the same flag** — Upcoming's cards cannot be picked up at all.
- `over.id` is therefore always a *column* id. There is no notion of dropping between two cards.
8. **Drop handling** — `handleDragEnd` at [TaskKanbanBoard.tsx:457](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx).
- `if (!target || target === UPCOMING_KEY) return;` — Upcoming drops are discarded.
- `if (currentKey === target) return;` — a same-column drop is a no-op, so vertical position is unreachable.
- Otherwise `moveTaskToGroup.mutateAsync({ taskId, groupId })`, then invalidates both lists.
9. **The server move** — `moveTaskToGroup` at [task-groups.ts:324](apps/server/src/trpc/routes/task-groups.ts).
- Updates `task_group_id`, then **unconditionally** resolves the destination group name and fires `relabelTaskForGroupMove` ([task-groups.ts:357](apps/server/src/trpc/routes/task-groups.ts)) to move the thread's Gmail label. Safe today only because step 8 never calls it for an unchanged group.
- Returns:
```json
{ "task": { "id": "t4", "taskGroupId": "g2", "taskOutput": { "kind": "email" } } }
```
10. **Snooze already has the dialog** — `DatePickerDialog` at the bottom of the board ([TaskKanbanBoard.tsx:686](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx)), driven by `snoozeTaskId` state and the `s` hotkey / card action, calling `optimisticSnoozeTask(id, date)` → `updateTaskDueDate` ([use-optimistic-task-actions.ts:505](apps/mail/modules/userTasks/hooks/use-optimistic-task-actions.ts)). Dismissing it writes nothing.
11. **The headless mirror** — `projectBoard` at [task-groups.ts:83](apps/server/src/cli/task-groups.ts) (`pnpm cedar-cli groups board`) reimplements the same bucketing and ordering server-side; it is the assertion surface for board membership without a browser.
## 3) Designed state
### 3.1 Architecture diagram
```text
9 insert sites (UNCHANGED) ──────► INSERT user_tasks
│
▼
┌─────────────────────────────────────────┐
│ BEFORE INSERT trigger │
│ user_tasks_place_new_row() │
│ midpoint of the two UNPINNED todo │
│ tasks bracketing NEW.due_date │
└─────────────────────────────────────────┘
│
sort_order set, sort_order_pinned = false
▼
listUserTasks (findMany — selects the new columns free)
│
useHydrateTasksSlice ──► userTasksSlice.tasks
│
▼
orderBy (URL view option)
┌────────────────────┴────────────────────┐
│ │
'manual' 'due-*' | 'created-desc'
│ │
sort by sort_order ASC sort by the field, live
│ (sort_order not consulted)
▼ │
SortableContext per column │
drag ──► arrayMove ──► midpoint of new neighbours │
──► sort_order_pinned = true │
──► moveTaskToGroup({ taskId, groupId, sortOrder }) │
▲ │
└──── restampSortOrder({ orderBy }) ◄───────┘
(on switching TO manual: re-seed
sort_order, clear every pin)
drop on Upcoming ──► DatePickerDialog ──► optimisticSnoozeTask (dueDate only)
└── dismissed ──► nothing written, card never moved
```
### 3.2 Step-by-step walkthrough
1. **A task is created — any of the nine sites, unchanged.** The insert omits `sort_order`; the drizzle column carries `.default(sql\`0\`)` purely so TypeScript keeps it optional on insert. The trigger overwrites that 0 unconditionally.
2. **`user_tasks_place_new_row()` fires** — new SQL function in [user_tasks_sort_order.sql](apps/server/src/db/migrations/user_tasks_sort_order.sql).
- Among the user's **unpinned todo** tasks, order agrees with due date by construction: later `due_date` ⇒ smaller `sort_order` (smaller sorts to the top).
- `prev` = the card directly above = smallest `due_date` still `> NEW.due_date`.
- `next` = the card directly below = largest `due_date` that is `<= NEW.due_date`.
- Result: `(prev + next) / 2`; `next - 1` if nothing is above; `prev + 1` if nothing is below; `0` if the user has no unpinned todo tasks.
- `due_date <= NEW.due_date` deliberately puts an equal-dated card *below* the newcomer, so among the many tasks defaulting to `now()` the newest lands on top.
- Board before, with `t3` pinned to the top by hand:
```json
[ { "id": "t3", "sort_order": 0.5, "due_date": "2026-08-20", "pinned": true },
{ "id": "t1", "sort_order": 1, "due_date": "2026-08-27", "pinned": false },
{ "id": "t2", "sort_order": 2, "due_date": "2026-08-25", "pinned": false } ]
```
- Inserting `t4` due `2026-08-26`: `t3` is skipped (pinned); `prev` = `t1` (1), `next` = `t2` (2).
```json
{ "id": "t4", "sort_order": 1.5, "due_date": "2026-08-26", "sort_order_pinned": false }
```
3. **`listUserTasks` returns the new columns for free** — the `findMany` at [user-tasks.ts:330](apps/server/src/trpc/routes/user-tasks.ts) selects the whole row. Its `orderBy` stays `due_date`; the board re-sorts client-side, and the wire order is irrelevant.
4. **The slice carries them** — `HydratedUserTask` at [userTasksSlice.ts:161](apps/mail/modules/userTasks/slice/userTasksSlice.ts) gains `sortOrder: number` and `sortOrderPinned: boolean`. `hydrateTodoTasks` stores whole objects, so no reconciler change.
5. **The board reads `orderBy`** — `useTaskListViewOptions` at [use-task-list-view-options.ts:38](apps/mail/modules/userTasks/hooks/use-task-list-view-options.ts); `TaskOrderBy` gains `'manual'`. New comparator in [task-order.ts](apps/mail/modules/userTasks/utils/task-order.ts):
```ts
export function compareTasks(orderBy: TaskOrderBy) {
if (orderBy === 'manual') return (a, b) => a.sortOrder - b.sortOrder;
if (orderBy === 'due-asc') return (a, b) => dueTime(a) - dueTime(b);
if (orderBy === 'created-desc') return (a, b) => createdTime(b) - createdTime(a);
return (a, b) => dueTime(b) - dueTime(a); // due-desc, today's behaviour
}
```
The `columns` useMemo at [TaskKanbanBoard.tsx:327](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) calls this instead of its inline sort. In a non-manual mode `sort_order` is never consulted, so due-date edits re-sort live.
6. **Switching TO manual re-stamps and unpins** — new `userTasks.restampSortOrder` in [user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts), called by the toolbar when `orderBy` changes to `'manual'`.
- One statement over the user's `status = 'todo'` rows: `sort_order = row_number() OVER (<the sort you were just in>)`, `sort_order_pinned = false`.
- Manual therefore always opens as exactly the view you were looking at, and no stale arrangement survives underneath.
```json
{ "restamped": 226, "orderBy": "due-desc" }
```
7. **Cards become sortable** — `TaskKanbanCard` swaps `useDraggable` ([TaskKanbanCard.tsx:147](apps/mail/modules/userTasks/components/TaskKanbanCard.tsx)) for `useSortable` from `@dnd-kit/sortable` (already a dependency at `10.0.0`), and each column wraps its cards in a `SortableContext`. `TaskKanbanColumn` keeps its `useDroppable` for the empty-column and whole-column case. `closestCorners` already suits sortable.
- `draggable` and `canDrop` are decoupled: `TaskKanbanColumn` gains a separate `canDrag` so Upcoming can accept drops *and* release its own cards.
8. **A vertical drop computes one number** — `handleDragEnd` at [TaskKanbanBoard.tsx:457](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx).
- `over.id` is now either a card id or a column id. Build the target column's id list, apply `arrayMove` to get the post-drop order, read the dragged card's two new neighbours, and midpoint their `sortOrder` (`first - 1` at the top, `last + 1` at the bottom).
- Dragging `t2` above `t4`, from the state in step 2: neighbours become `t1` (1) and `t4` (1.5).
```json
{ "id": "t2", "sort_order": 1.25, "sort_order_pinned": true }
```
- Write to the slice, `cancelQueries` on `listUserTasks`, then call the server — the cancel-then-mutate order documented in [task-board-data-flow.md](apps/mail/docs/wiki/task-board-data-flow.md), without which an in-flight fetch re-hydrates the old order over the drop.
- Vertical drags are inert outside manual mode; cross-column drags still re-file.
9. **One procedure persists the whole gesture** — `moveTaskToGroup` at [task-groups.ts:324](apps/server/src/trpc/routes/task-groups.ts) gains `sortOrder: z.number().optional()`, writing `sort_order` and `sort_order_pinned = true` alongside `task_group_id`.
- **Required change:** the `relabelTaskForGroupMove` call at [task-groups.ts:357](apps/server/src/trpc/routes/task-groups.ts) becomes conditional on the group actually changing. Today it is unconditional and safe only because `handleDragEnd` returns early on a same-column drop — a condition this design removes.
10. **Upcoming accepts drops** — its column literal at [TaskKanbanBoard.tsx:380](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) flips to `canDrop: true`, and `handleDragEnd` drops its `target === UPCOMING_KEY` early return.
- The drop writes **nothing**. It sets `pendingUpcomingTaskId`, which opens the existing `DatePickerDialog` ([TaskKanbanBoard.tsx:686](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx)).
- On select → `optimisticSnoozeTask(taskId, date)`, which patches `dueDate` in the slice; the card re-derives into Upcoming on the next render. `sort_order` is untouched — the card keeps its place in line, in a different line.
```json
{ "id": "t2", "due_date": "2026-09-03T08:00:00Z", "sort_order": 1.25 }
```
- On dismiss → clear `pendingUpcomingTaskId`. Because column membership derives from `dueDate` and `dueDate` never changed, the card is still in its group column. **There is no rollback path to write** — the card never moved.
11. **Dragging out of Upcoming means "do it now"** — a card dropped from Upcoming into a group column sets `dueDate` to now *and* re-files the group, in the same mutation. Without the date change the card would snap straight back to Upcoming and read as broken.
12. **The headless mirror follows** — `projectBoard` at [task-groups.ts:83](apps/server/src/cli/task-groups.ts) sorts by `sortOrder` and prints the pin flag, so `pnpm cedar-cli groups board` remains the browser-free assertion surface.
### 3.3 Schema
Full schema:
```sql
-- apps/server/src/db/migrations/user_tasks_sort_order.sql (idempotent, hand-authored)
ALTER TABLE user_tasks
ADD COLUMN IF NOT EXISTS sort_order double precision NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sort_order_pinned boolean NOT NULL DEFAULT false;
-- Render order for a manual-mode board.
CREATE INDEX IF NOT EXISTS idx_user_tasks_user_sort_order
ON user_tasks (user_id, sort_order);
-- The trigger's two bracketing lookups.
CREATE INDEX IF NOT EXISTS idx_user_tasks_unpinned_due
ON user_tasks (user_id, due_date)
WHERE status = 'todo' AND NOT sort_order_pinned;
-- One-time seed: preserve exactly what the board shows today (due_date DESC). Guarded PER
-- USER on "nothing has placed a row here yet" — a row-level `sort_order = 0` check is not
-- idempotent, because 0 is a value placement legitimately produces.
UPDATE user_tasks t
SET sort_order = r.rn
FROM (
SELECT id, row_number() OVER (PARTITION BY user_id ORDER BY due_date DESC, id) AS rn
FROM user_tasks
WHERE status = 'todo'
AND user_id IN (
SELECT user_id FROM user_tasks WHERE status = 'todo'
GROUP BY user_id
HAVING bool_and(sort_order = 0) AND bool_and(NOT sort_order_pinned)
)
) r
WHERE t.id = r.id;
-- Place a new row where its due date belongs among the cards the user has NOT moved.
CREATE OR REPLACE FUNCTION user_tasks_place_new_row() RETURNS trigger AS $$
DECLARE
prev double precision; -- the unpinned card directly ABOVE (later due date)
next double precision; -- the unpinned card directly BELOW (earlier or equal due date)
BEGIN
SELECT sort_order INTO prev FROM user_tasks
WHERE user_id = NEW.user_id AND status = 'todo' AND NOT sort_order_pinned
AND due_date > NEW.due_date
ORDER BY due_date ASC LIMIT 1;
SELECT sort_order INTO next FROM user_tasks
WHERE user_id = NEW.user_id AND status = 'todo' AND NOT sort_order_pinned
AND due_date <= NEW.due_date
ORDER BY due_date DESC LIMIT 1;
NEW.sort_order := CASE
WHEN prev IS NOT NULL AND next IS NOT NULL THEN (prev + next) / 2
WHEN next IS NOT NULL THEN next - 1
WHEN prev IS NOT NULL THEN prev + 1
ELSE 0
END;
NEW.sort_order_pinned := false;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS user_tasks_place_new_row_trg ON user_tasks;
CREATE TRIGGER user_tasks_place_new_row_trg
BEFORE INSERT ON user_tasks
FOR EACH ROW EXECUTE FUNCTION user_tasks_place_new_row();
```
```ts
// apps/server/src/db/aop-schema.ts — userTasks, the two new columns in context.
export const userTasks = pgTable('user_tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
conversationId: uuid('conversation_id').notNull()
.references(() => crmConversations.id, { onDelete: 'cascade' }),
taskGroupId: uuid('task_group_id').references(() => taskGroups.id, { onDelete: 'set null' }),
taskChannel: text('task_channel').$type<TaskChannel>(),
taskType: text('task_type').$type<TaskType>(),
taskCreatedBy: text('task_created_by').$type<'agent' | 'user'>(),
taskActionData: jsonb('task_action_data').$type<TaskActionData>(),
taskOutput: jsonb('task_output').$type<TaskOutput>(),
sourceThreadId: text('source_thread_id'),
agentExecutionEnabled: boolean('agent_execution_enabled').notNull().default(false),
executionRunId: text('execution_run_id'),
creationRunId: text('creation_run_id'),
notes: text('notes'),
description: text('description'),
status: text('status').$type<TaskStatus>().notNull().default('todo'),
isRead: boolean('is_read').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
dueDate: timestamp('due_date').notNull().defaultNow(),
chatThreadId: text('chat_thread_id'),
tags: text('tags').array().$type<TaskTag[]>().notNull().default(sql`'{}'::text[]`),
// NEW — where the card sits. Smaller sorts to the top. The `.default(0)` exists ONLY so
// drizzle keeps this optional on insert; the BEFORE INSERT trigger overwrites the 0
// unconditionally, so a literal 0 is never observed on a real row.
sortOrder: doublePrecision('sort_order').notNull().default(0),
// NEW — did the user put it there by hand? Pinned cards hold their slot and are skipped
// as reference points when placing a newly created task. Cleared wholesale by
// restampSortOrder when the board switches back to manual ordering.
sortOrderPinned: boolean('sort_order_pinned').notNull().default(false),
});
```
```ts
// apps/mail/modules/userTasks/slice/userTasksSlice.ts
export interface HydratedUserTask {
id: string;
userId: string;
conversationId: string;
taskGroupId: string | null;
taskChannel: TaskChannel;
taskType: TaskType | null;
taskCreatedBy: 'agent' | 'user' | null;
taskActionData: ConversationTaskActionData | null;
taskOutput: ConversationUserTask['taskOutput'];
agentExecutionEnabled: boolean;
executionRunId: string | null;
creationRunId: string | null;
notes: string | null;
chatThreadId: string | null;
sourceThreadId: string | null;
description: string | null;
status: 'todo' | 'done' | 'deleted' | 'agent_deleted';
tags: string[];
isRead: boolean;
createdAt: Date;
updatedAt: Date;
completedAt: Date | null;
dueDate: Date;
sortOrder: number; // NEW — mirrors user_tasks.sort_order
sortOrderPinned: boolean; // NEW — mirrors user_tasks.sort_order_pinned
conversation?: HydratedTaskConversation | null;
}
// apps/mail/modules/userTasks/hooks/use-task-list-view-options.ts
export type TaskOrderBy =
| 'manual' // NEW — sort by sortOrder; the only mode where dragging reorders
| 'due-asc'
| 'due-desc' // default, unchanged
| 'created-desc';
// apps/mail/modules/userTasks/utils/task-order.ts (NEW FILE)
export function compareTasks(
orderBy: TaskOrderBy,
): (a: OrderableTask, b: OrderableTask) => number;
export interface OrderableTask {
sortOrder: number;
dueDate?: string | Date | null;
createdAt?: string | Date | null;
}
/** The dragged card's new sortOrder, from the column order after arrayMove. */
export function sortOrderForDrop(
column: readonly OrderableTask[], // post-arrayMove
index: number, // the dragged card's new index
): number;
```
```ts
// apps/server/src/trpc/routes/task-groups.ts — moveTaskToGroup input (CHANGED)
z.object({
taskId: z.string().uuid(),
groupId: z.string().uuid().nullable(),
sortOrder: z.number().optional(), // NEW — present ⇒ also set sort_order_pinned = true
});
// apps/server/src/trpc/routes/user-tasks.ts — restampSortOrder (NEW)
z.object({
orderBy: z.enum(['due-asc', 'due-desc', 'created-desc']),
});
// → { restamped: number }
```
Relationship diagram:
```text
┌────────────────────────┐
│ user │
│ id (text) PK │
└───────────┬────────────┘
│ 1:N (user_id)
▼
┌─────────────────────────────────────────────┐ ┌──────────────────────────┐
│ user_tasks │ │ task_groups │
│ id uuid PK │ │ id uuid PK │
│ user_id text ──FK──► user.id │ │ user_id text ──FK──► │
│ conversation_id uuid ──FK──► │ │ name text │
│ crm_conversations.id │ │ color text │
│ task_group_id uuid ──FK──►────────────┼───N:1──► icon text │
│ (ON DELETE SET NULL; │ │ position integer │
│ NULL = virtual "Misc") │ │ routing_criteria text │
│ due_date timestamp NOT NULL │ │ overdue_policy jsonb │
│ status text NOT NULL │ │ agent_visible boolean │
│ sort_order float8 NOT NULL (NEW) │ └──────────────────────────┘
│ sort_order_pinned boolean NOT NULL (NEW) │
│ task_output jsonb │
│ ▼ contains { kind, threadId?, … } │
│ task_action_data jsonb │
│ ▼ contains { threadId?, draftId?, … } │
│ tags text[] │
└─────────────────────────────────────────────┘
│
│ BEFORE INSERT
▼
┌──────────────────────────────────────────────────────────┐
│ user_tasks_place_new_row() │
│ reads: user_tasks WHERE user_id = NEW.user_id │
│ AND status = 'todo' AND NOT sort_order_pinned │
│ writes: NEW.sort_order, NEW.sort_order_pinned = false │
└──────────────────────────────────────────────────────────┘
Ordering contract (unpinned todo rows only):
later due_date ⇔ smaller sort_order ⇔ nearer the top
pinned rows are exempt — they hold their slot and are skipped as reference points.
One global sort_order serves every columnBy axis (group | due | channel): a total order
restricted to any subset is still a valid order for that subset.
```
## 4) Implementation phases
### Phase 1 — Schema, trigger, backfill
**Goal:** `sort_order` and `sort_order_pinned` exist and are correct on every row; nothing reads them yet.
- [x] Add `sortOrder` and `sortOrderPinned` to `userTasks` in [aop-schema.ts:1396](apps/server/src/db/aop-schema.ts), with the comment explaining that `.default(0)` exists only to keep the drizzle insert type optional.
- [x] Create [user_tasks_sort_order.sql](apps/server/src/db/migrations/user_tasks_sort_order.sql) with the two `ADD COLUMN IF NOT EXISTS` statements, following the idempotent hand-authored convention in [README.md](apps/server/src/db/migrations/README.md).
- [x] Add `idx_user_tasks_user_sort_order` and the partial `idx_user_tasks_unpinned_due` to the same file.
- [x] Add the one-time seed `UPDATE` that stamps `row_number() OVER (PARTITION BY user_id ORDER BY due_date DESC, id)` onto todo rows, guarded per user on every row still being at the default so re-running is safe.
- [x] Add `user_tasks_place_new_row()` and its `BEFORE INSERT` trigger to the same file, modelled on the existing trigger in [channel_link_store_unlink.sql](apps/server/src/db/migrations/channel_link_store_unlink.sql).
- [x] Apply to the dev database and confirm a task created through `pnpm cedar-cli` lands with a non-zero `sort_order` between the right neighbours.
**Tests:**
- [x] New `apps/server/src/db/__tests__/user-tasks-sort-order.test.ts` — the placement rule as a pure function mirroring the trigger's CASE: both neighbours, top only, bottom only, empty column, and equal `due_date` (newcomer goes above).
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/db/__tests__/user-tasks-sort-order.test.ts`
- [x] `timeout 300 pnpm --filter @zero/server run types`
### Phase 2 — Read path and manual ordering mode
**Goal:** the board honours `orderBy`, including a new `manual` mode that renders `sort_order`. Order is visible and correct; dragging cannot yet change it.
- [x] Add `sortOrder: number` and `sortOrderPinned: boolean` to `HydratedUserTask` at [userTasksSlice.ts:161](apps/mail/modules/userTasks/slice/userTasksSlice.ts).
- [x] Add `'manual'` to `TaskOrderBy` at [use-task-list-view-options.ts:10](apps/mail/modules/userTasks/hooks/use-task-list-view-options.ts).
- [x] Create [task-order.ts](apps/mail/modules/userTasks/utils/task-order.ts) with `compareTasks(orderBy)` and `sortOrderForDrop(column, index)`.
- [x] Replace the inline `bucket.sort((a, b) => dueTime(b) - dueTime(a))` in the `columns` useMemo at [TaskKanbanBoard.tsx:327](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) with `compareTasks(orderBy)`, and destructure `orderBy` from `useTaskListViewOptions`.
- [x] Add `{ value: 'manual', label: 'Manual' }` to `ORDER_BY_OPTIONS` at [TasksToolbar.tsx:57](apps/mail/modules/userTasks/components/TasksToolbar.tsx) and render the Ordering row in the kanban branch at [TasksToolbar.tsx:276](apps/mail/modules/userTasks/components/TasksToolbar.tsx).
- [x] Seed `sortOrder: 0` / `sortOrderPinned: false` on the optimistic row in [use-create-task-optimistic.ts:50](apps/mail/modules/userTasks/hooks/use-create-task-optimistic.ts) so the temp task type-checks; the server's value replaces it on hydration.
**Tests:**
- [x] New `apps/mail/tests/modules/userTasks/taskOrder.test.ts` — `compareTasks` for each of the four modes, including that `manual` ignores `dueDate` and `due-desc` ignores `sortOrder`.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest tests/modules/userTasks/taskOrder.test.ts`
- [x] `timeout 300 pnpm --filter @zero/mail run types`
### Phase 2.5 — Concurrency-safe placement
Found on staging the day after phase 1 landed, and growing: two tasks inserted in the same instant
read the same neighbours and compute the same midpoint, so they land on an identical `sort_order`.
Agent task creation runs in bursts, which is exactly the shape that collides. It compounds — the
next insert into that region picks its neighbours by due date and inherits their now-wrong order.
- [x] Give the render tiebreak meaning: `compareTasks('manual')` falls back to `dueDate` descending and then `id`, so a collision is *harmless* rather than dependent on the order the wire happened to deliver.
- [x] Serialize placement per user: `pg_advisory_xact_lock(hashtext(p_user_id))` at the top of `user_tasks_place_by_due_date` in [user_tasks_sort_order.sql](apps/server/src/db/migrations/user_tasks_sort_order.sql), removing the collision at the source.
- [x] Mirror the same tiebreak in `projectBoard`, so the headless board and the browser resolve a collision identically.
**Tests**
- [x] `taskOrder.test.ts` — tied `sortOrder`s resolve to the same order whatever the input order.
- [x] `apps/server/src/cli/__tests__/task-groups.test.ts` — the same collision, through the CLI projection.
**Still to apply.** The advisory lock is a `CREATE OR REPLACE FUNCTION` against the shared staging
database (there is no local Postgres), so it is written but not applied. Existing collisions —
53 groups as of 2026-08-30 — are repaired by re-running the seed for the affected users, which is
safe now that its guard is per user.
### Phase 3 — Re-stamp on switching to manual
**Goal:** picking Manual re-seeds `sort_order` from the sort you were just in and clears every pin, so no stale arrangement survives a mode switch.
- [x] Add `restampSortOrder` to [user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts): one `UPDATE … FROM (SELECT row_number() OVER (…))` over the caller's `status = 'todo'` rows, setting `sort_order` and `sort_order_pinned = false`; returns `{ restamped }`.
- [x] Map the three non-manual `TaskOrderBy` values to their SQL order expressions in that procedure, defaulting to `due_date DESC`.
- [x] Call it from the toolbar's `setOrderBy` at [TasksToolbar.tsx:287](apps/mail/modules/userTasks/components/TasksToolbar.tsx) when the new value is `'manual'`, passing the value being switched away from.
- [x] Invalidate `listUserTasks` on success so the slice re-hydrates with the new order.
**Tests:**
- [x] New `apps/server/src/trpc/routes/__tests__/restamp-sort-order.test.ts` — the row_number mapping produces a strictly increasing `sort_order` matching each input order, and every pin is cleared.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/trpc/routes/__tests__/restamp-sort-order.test.ts`
- [x] `timeout 300 pnpm --filter @zero/server run types`
### Phase 4 — Drag to reorder
**Goal:** in manual mode, dragging a card to a new vertical position writes one number and one flag, and it sticks.
- [x] Add `sortOrder: z.number().optional()` to `moveTaskToGroup` at [task-groups.ts:325](apps/server/src/trpc/routes/task-groups.ts), writing `sort_order` and `sort_order_pinned = true` when present.
- [x] Gate the `relabelTaskForGroupMove` call at [task-groups.ts:357](apps/server/src/trpc/routes/task-groups.ts) on the group having actually changed — required, since same-column drops now reach this procedure.
- [x] Swap `useDraggable` for `useSortable` in [TaskKanbanCard.tsx:147](apps/mail/modules/userTasks/components/TaskKanbanCard.tsx).
- [x] Wrap each column's cards in a `SortableContext` in [TaskKanbanColumn.tsx](apps/mail/modules/userTasks/components/TaskKanbanColumn.tsx), keeping its `useDroppable` for the empty-column case.
- [x] Split `canDrag` from `canDrop` on `TaskKanbanColumn` and stop passing `draggable={col.canDrop}` at [TaskKanbanBoard.tsx:577](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx).
- [x] Rewrite `handleDragEnd` at [TaskKanbanBoard.tsx:457](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) to resolve `over.id` as either a card or a column id, place the card, and derive the new `sortOrder` via `sortOrderForDrop`. The decision landed as a pure `planTaskDrop` in [task-drop-plan.ts](apps/mail/modules/userTasks/utils/task-drop-plan.ts) rather than inside the handler, so every branch of the four-way matrix (reorder / re-file / reschedule / pull-back) is reachable from a test.
- [x] Make vertical reordering inert unless `orderBy === 'manual'`; cross-column drags keep working in every mode.
- [x] Write the new `sortOrder` / `sortOrderPinned` into the slice, then `cancelQueries` on `listUserTasks` before the server call, per [task-board-data-flow.md](apps/mail/docs/wiki/task-board-data-flow.md).
**Tests:**
- [x] Extend `apps/mail/tests/modules/userTasks/taskOrder.test.ts` with `sortOrderForDrop`: dropped at the top, at the bottom, between two cards, and into an empty column.
- [x] New `apps/mail/tests/modules/userTasks/taskKanbanDrop.test.ts` — the place → neighbours → midpoint resolution for a card id target and a column id target.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest tests/modules/userTasks`
- [x] `timeout 300 pnpm --filter @zero/server run types` and `timeout 300 pnpm --filter @zero/mail run types`
### Phase 5 — Upcoming as a drop target
**Goal:** dropping a card on Upcoming opens the snooze dialog; dismissing it leaves the card exactly where it was, with nothing written.
- [x] Flip the Upcoming column literal to `canDrop: true` at [TaskKanbanBoard.tsx:380](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) and update its comment.
- [x] Remove the `target === UPCOMING_KEY` early return in `handleDragEnd` at [TaskKanbanBoard.tsx:461](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx).
- [x] Add `pendingUpcomingTaskId` state; an Upcoming drop sets it and writes nothing else.
- [x] Drive the existing `DatePickerDialog` at [TaskKanbanBoard.tsx:686](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) from `snoozeTaskId ?? pendingUpcomingTaskId`, titled "Snooze until".
- [x] On select, call `optimisticSnoozeTask(taskId, date)` and leave `sortOrder` untouched; on dismiss, clear `pendingUpcomingTaskId` and write nothing.
- [x] Set `canDrag: true` on the Upcoming column, and make a drop from Upcoming into a group column set `dueDate` to now alongside the re-file.
**Tests:**
- [x] New `apps/mail/tests/modules/userTasks/upcomingDrop.test.ts` — a resolved drop yields a `dueDate` change and no `sortOrder` change; a dismissed drop yields no writes at all; a drop out of Upcoming yields both a group change and a `dueDate` of today.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest tests/modules/userTasks/upcomingDrop.test.ts`
- [x] `timeout 300 pnpm --filter @zero/mail run types`
### Phase 6 — Headless mirror and wiki
**Goal:** the browser-free assertion surface and the module wiki describe the ordering that actually ships.
- [x] Sort by `sortOrder` in `projectBoard` at [task-groups.ts:83](apps/server/src/cli/task-groups.ts) and print the pin flag per task.
- [x] Update the `groups board` header comment at [task-groups.ts:15](apps/server/src/cli/task-groups.ts), which currently documents `dueDate` ascending.
- [x] Update the Ordering section of [TASK_KANBAN_DESIGN.md:145](apps/mail/modules/userTasks/TASK_KANBAN_DESIGN.md), which marks ordering DEFERRED and describes a `priorityScore` approach this design supersedes.
- [x] Add an ordering section to [task-board-data-flow.md](apps/mail/docs/wiki/task-board-data-flow.md) covering the pinned/unpinned contract and why a dismissed Upcoming drop needs no rollback.
**Tests:**
- [x] Extend `apps/server/src/cli/__tests__` with a `projectBoard` ordering case: pinned cards hold their slot while unpinned cards follow `sortOrder`.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/cli/__tests__`
- [x] `CEDAR_API_URL=http://localhost:8790 pnpm cedar-cli groups board` against the dev server and confirm the printed order matches the browser.
### Phase 7 — Apply the placement hardening, and repair the damage it prevents
**Goal:** zero `sort_order` collisions in the database, no path left that can create a new one, and
every part of the ordering surface reachable from the command line.
Phase 2.5 made a collision *harmless* at render time and wrote the lock that prevents it. Neither
half of the prevention is live: the SQL is not applied, and the rows already corrupted are still
corrupted — 29 collision groups on 2026-08-29, 53 on 2026-08-30 morning, 61 by that evening. It
compounds, because `user_tasks_place_by_due_date` picks neighbours by due date and assumes their
`sort_order`s are already in agreement, so each new insert into a damaged region inherits the
mistake.
The repair is not a one-off script. `restampSortOrder` already renumbers a user's open tasks 1..n
from a chosen ordering — which is exactly what repairing a collision means — so the repair is that
same statement, applied per affected user. Making it a durable, re-runnable verb rather than a
migration is the point: placement will drift again (a bad deploy, a bulk import, a restored
backup), and the answer should be a command, not an archaeology session.
**Three defects found while building this phase**, each fixed in the same `CREATE OR REPLACE`:
1. **The lock was in the wrong keyspace.** `pg_advisory_xact_lock(hashtext(user_id))` is correct
but shares the one-argument advisory keyspace with the document-save locks and the CRM
reconciliation cron's *session*-level lock — which is held for the length of the run, so a user
whose id hashed onto that day's key would have every task creation stall for minutes. Measured:
no such collision today across 205 users × 127k documents, but ~1.2% likely now and ~69% at ten
times the data. Now namespaced into the two-argument form, following `AOP_PING_LOCK_NAMESPACE`.
2. **Neighbour selection was non-deterministic on equal due dates.** Both bracketing queries were
`ORDER BY due_date … LIMIT 1` with no secondary key. `due_date` defaults to `now()`, so ties are
the common case the file's own comment calls common — and among a group of peers Postgres
returns an arbitrary one, dropping the newcomer into the middle of its own peers instead of on
top. The advisory lock does nothing about this; it needed its own tiebreak on both lookups.
3. **A reopened task kept a dead position, permanently.** Completing a card and reopening it (or
restoring a deleted one, or accepting a recommendation) left it holding a `sort_order` measured
against a board that had moved on — and if it was pinned, every correction path was exempt from
touching it, so it held that slot forever. The UPDATE trigger now re-places any row re-entering
`status = 'todo'` and clears its pin.
**Tasks:**
- [x] Namespace the advisory lock into the two-argument form and document the READ COMMITTED assumption it depends on (under REPEATABLE READ the lock is inert — measured, and silent).
- [x] Add a `sort_order` tiebreak to both neighbour lookups in `user_tasks_place_by_due_date`, and mirror it in the spec twin at [sort-order.ts](apps/server/src/services/user-tasks/sort-order.ts).
- [x] Re-place and unpin a row re-entering `status = 'todo'` in `user_tasks_replace_on_due_change`, and correct the comment claiming the third exemption is what makes the board's drag safe — the PIN is; that clause is dead code for this caller.
- [x] Apply the corrected `user_tasks_place_by_due_date`, `user_tasks_place_new_row` and `user_tasks_replace_on_due_change` to the database. Verified by reading the live `prosrc` back: namespaced lock, both tiebreaks, re-entry branch.
- [x] Add `userTasks.auditSortOrder` — a read-only cross-user audit returning, per user, the collision groups, the surplus rows, and the genuine ordering inversions.
- [x] Add `userTasks.repairSortOrder` — `compact` (renumber in the current render order; kills collisions, moves nothing visible, keeps every pin) and `reseed` (renumber by due date; the only thing that fixes an inversion, and refuses a user with pinned rows unless forced). `dryRun` defaults to true.
- [x] Add `userTasks.stressSortOrderPlacement` — N concurrent inserts through the real trigger, reporting whether any two collided, cleaning up after itself even on failure.
- [x] All three are `cedarAdminProcedure`: the damage spans users, so it cannot be scoped to a session user the way `restampSortOrder` is.
- [x] `tasks sort-order audit|repair|restamp|stress` in [task-admin/cli.ts](apps/server/src/task-admin/cli.ts), each a thin HTTP client over the running server's tRPC API. `restamp` drives the per-user procedure that shipped in phase 3 with no headless entry point at all.
- [x] Fix the `--sort-order` falsy guard in [task-groups.ts](apps/server/src/cli/task-groups.ts): `0` is a real placement (it is what `sortOrderForDrop` returns for an empty column) and was being silently dropped.
- [x] Order the execution sidebar's lanes with `compareTasks(orderBy)` in [use-task-group-buckets.ts](apps/mail/modules/userTasks/hooks/use-task-group-buckets.ts). It sorted by due-date ascending regardless of the toolbar, so a card dragged to the top of a lane on the board appeared somewhere else in the sidebar for that same lane.
- [x] Run `tasks sort-order repair --mode reseed --apply` and leave the audit green. 28 users, 4461 rows, 0 skipped.
**Tests:**
- [x] New `apps/server/src/db/__tests__/sort-order-audit.test.ts` — the collision and inversion detectors, including that a PINNED pair ordered against its due dates is NOT an inversion (pinning is precisely the statement that position and date have been decoupled) and that a collision is not double-reported as an inversion.
- [x] Extend `apps/server/src/db/__tests__/user-tasks-sort-order.test.ts` with the equal-due-date cases the tiebreak exists for — landing above every peer rather than among them, independent of arrival order, and taking the bottom of an equal-dated group above.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/db/__tests__` — 30 passed.
- [x] `timeout 300 pnpm --filter @zero/server run types` and `timeout 900 pnpm --filter @zero/mail run types`.
- [x] `cedar-cli tasks sort-order stress --for <email> --count 25` against the dev server: **13 collisions, exit 1** — the probe reproduces the fault it exists to catch, and cleaned up its 25 rows.
- [x] Re-run `stress` after the SQL is applied: **25 inserts, 25 distinct sort_orders, 0 collisions** — the same command that reported 13 a moment earlier.
- [x] `cedar-cli tasks sort-order audit` reports 0 collision groups and 0 inversions across every user — 96 users, 9268 open tasks, `healthy: true`.
- [x] The re-entry branch, proved against the live database inside a rolled-back transaction: a card pinned at 9999 holds its slot through a due-date change (the pin exemption), and `done → todo` returns it to `sort_order: 1` with the pin cleared.
**Independent evidence for the lock**, gathered on a faithful clone of the table (never the real
one), since the SQL cannot be unit-tested in-process — the test DB builds its DDL from the drizzle
schema, which carries the columns but not the triggers:
| burst | without the lock | with it |
|---|---|---|
| 30 concurrent inserts, one user | 30 of 30 rows collided, across 2 distinct values; 28 inversions | 30 distinct values, 0 inversions |
| 12 inserts + 12 due-date updates | 23 of 24 rows collided | 24 distinct values |
| 30 inserts across 30 *different* users | 236 ms | 228 ms — no contention, no cost |
Server-side cost per insert: 72µs → 76µs. The serialisation is the point; it is not the expense.
### Phase 8 — One order, no modes
**Reported:** "The drag and drop for reordering within a single column doesn't seem to work at all.
None of the other cards move out of the way." Correct on both counts, for two independent reasons.
**Fault 1 — the feature was gated on a mode nobody was in.** `SortableContext` was only rendered
when `orderBy === 'manual'`. Without it dnd-kit treats the cards as plain draggables: no gap opens
as you drag, and a drop has no between-cards target, so the card springs back. Since `manual` lived
in a popover and the default is `due-desc`, the ordinary experience of the feature was that it did
nothing.
**Fault 2 — the overlay stole the dragged card's identity.** `DragOverlay` renders a *second*
`TaskKanbanCard` for the card being dragged. Hooks cannot be skipped, so it called `useSortable`
under the same `task.id`. dnd-kit keys its registry by id, and the overlay never receives a node
(it is rendered `draggable={false}`, so `setNodeRef` is never called) — so the duplicate clobbered
the ACTIVE card's entry with a null rect, `verticalListSortingStrategy` had nothing to measure, and
no card moved out of the way. **This one would have broken the animation even inside `manual`
mode**, and it is invisible to types and to every pure-function test.
The design decision that follows, and which supersedes the "sort modes are live comparators"
decision recorded at the top of the handoff: **there is one order, and the Ordering control
re-seeds it.** No `manual` member, no render mode, drag always writes. Liveness is not lost — the
database maintains `sort_order` against due date for unpinned rows, so snoozing still moves a card.
- [x] Drop `'manual'` from `TaskOrderBy` and from the toolbar's options.
- [x] `compareTasks()` takes no argument and always orders by `sortOrder` (tiebreak: due date descending, then id). The board, the list and the execution sidebar all call it.
- [x] Every ordering change calls `restampSortOrder` with the mode just chosen, so the control changes the DATA rather than the comparator.
- [x] Every column renders a `SortableContext`; `canDrag` is unconditional.
- [x] `planTaskDrop` always returns a `sortOrder` — the plan's field is no longer optional.
- [x] `sortableCardId(taskId, isOverlay)` in [task-drop-plan.ts](apps/mail/modules/userTasks/utils/task-drop-plan.ts), so the overlay cannot collide with the card it is drawing.
- [x] Add `onDragOver` to highlight the lane under the cursor. A drop onto a CARD reports over-the-card rather than over-the-column, so `useDroppable`'s own `isOver` went dark exactly when you were aiming most precisely; the board now passes the resolved column down.
**Tests:**
- [x] `taskOrder.test.ts` rewritten for the single order — including that `compareTasks` takes no argument, that a missing `sortOrder` reads as 0 rather than NaN, and that negative and fractional placements order correctly.
- [x] `upcomingDrop.test.ts` — a drop places the card under every `columnBy`, and `sortableCardId` gives the overlay an id that resolves to no card.
- [x] `timeout 600 pnpm --filter @zero/mail exec jest tests/modules/userTasks` — 235 passed.
- [x] `timeout 900 pnpm --filter @zero/mail run types`.
- [x] Per-mode re-seed proved against the running server: `restamp --from due-asc` flips the Follow-ups lane, `--from due-desc` restores it, and the audit stays green.
**Environment note that cost time here.** Two checkouts serve a frontend at once — `localhost:3001`
is cedar-mail-1 and `localhost:3002` is cedar-mail-2. A change tested on the wrong one looks exactly
like a change that does not work.
### Phase 9 — The drag keeps up with the cursor
**Reported:** "When I move tasks between columns on the kanban board, it is delayed and moves a
second after I move it with my mouse."
The gesture was correct and the arrangement it computed was correct; both arrived late. Four
independent costs, all of them paid per pointer event, on a surface that renders up to 500 tasks:
**1 — Every card re-rendered on every frame.** `TaskKanbanCard` is `memo`'d, but the board built
six callbacks inside its `map` — a new prop identity each render, so the memo never once bailed
out. At rest that is invisible. Mid-drag the state driving the arrangement changes every frame, so
every frame re-ran `useSortable`, the shimmer subscription, the executing-thread lookup and the
selection subscription for several hundred cards, *between* the cursor moving and the overlay being
drawn in its new place. The overlay is positioned by React: whatever the board spends is the gap.
**2 — A forced layout per lane per event.** `laneAtPoint` hit-tested by `querySelectorAll` +
`getBoundingClientRect` on every move. The lane rects are cacheable for exactly the reason the
pointer is trusted over dnd-kit's `over` in the first place — lanes hold still, their contents move.
**3 — Nothing coalesced to a frame.** Pointer events outpace the display, so the board was
re-arranging several times per painted frame and rendering a queue of positions the cursor had
already left.
**4 — The hover work fired for every card crossed.** A drag between two lanes sweeps the cursor over
every card in between; each one set the hotkey target (re-rendering the board) and fired two
speculative fetches, for cards the user is demonstrably not aiming at.
**And the delay at the end of the gesture**, which is the one that is literally a delay rather than
a lag: `DragOverlay`'s default drop animation is a 250ms flight of the overlay back to the rect the
card was measured at — its *old* column — after which it vanishes and the real card appears in the
new one. The optimistic write lands in the same commit that ends the drag, so there is nothing to
wait for; the animation was replaying the move the user had just finished making.
- [x] `handleToggleComplete` / `handleDelete` / `handleOpen` / `handleExecute` are `useCallback`s on
the board; `onSnooze` and `onOpenConversation` pass the already-stable setter and module
function directly.
- [x] `TaskKanbanCard`'s `onExecute` takes the task rather than closing over it (matching
`TaskListRow`), so every card that offers Execute can share one function — the per-card
decision left is only *whether* to offer it, which is a boolean. Updated in
[TaskExecutionList.tsx](apps/mail/modules/userTasks/components/TaskExecutionList.tsx) and
[OpenTaskExecutionCard.tsx](apps/mail/modules/userTasks/components/OpenTaskExecutionCard.tsx).
- [x] `measureLaneRects()` captures every lane's rect at drag start; `laneAtPoint` hit-tests the
capture. Re-taken on `resize` and on `scroll` (capture phase — the board's own horizontal
scroller does not bubble), the two things that can actually move a lane.
- [x] The pointer listener records the latest position and resolves once per `requestAnimationFrame`;
the frame is cancelled on cleanup.
- [x] `setOverColumnKey` takes the functional guard `dragPlacement` already had.
- [x] `TaskKanbanCard` skips its hover work — the hotkey target and the thread prefetch — while
`useSortable` reports a drag in flight.
- [x] `<DragOverlay dropAnimation={null}>`.
**Tests:**
- [x] New `apps/mail/tests/modules/userTasks/taskKanbanCardExecute.test.tsx` — two cards sharing one
`onExecute` each receive their own task, and a surface that offers no Execute renders no
button. Pins the signature change, which nothing else would notice.
- [x] `timeout 600 pnpm --filter @zero/mail exec jest tests/modules/userTasks` — 248 passed.
- [x] `timeout 900 pnpm --filter @zero/mail run types`.
### Phase 10 — One answer about where the card lands
**Reported:** "The hitbox is a little weird. If I drag it above a certain area it totally makes
sense, but sometimes, even when the card moves out of the way and is in the correct position when I
drop it, it'll actually go back to its point. The drop indication animation and the hitbox are not
the same as the actual final drop decision."
Correct, and literally so: there were two systems arranging the lane, and only one of them was
consulted about the write.
**The board's answer.** `displayColumns` renders the dragged card at the slot the pointer resolved,
so the cards part through plain DOM order, and `planTaskDrop` reads the position back off that same
list. Self-consistent by construction — the frozen midpoints and the moving hole agree, because the
hole is a card-sized space the geometry was measured with.
**dnd-kit's answer, layered on top.** Every lane also ran `verticalListSortingStrategy`, which
displaces by `activeIndex` vs `overIndex` — indices into `items`, which is the list the board has
*already* rearranged, against an `over` that dnd-kit resolves with its own `closestCorners` over
rects measured before the drag. Nothing ties that to the pointer maths.
Worked through, dragging C down in `[A, B, C, D, E]`:
| cursor | board's slot | rendered order | dnd-kit displaces | what the user sees |
|---|---|---|---|---|
| upper half of D | 2 (unchanged) | `A B [C] D E` | `over`=D, overIndex 3 > activeIndex 2 → D moves **up** | `A B [D] gap E` — reads as "lands after D" |
| past D's midpoint | 3 | `A B D [C] E` | `over`=D, overIndex 2 < activeIndex 3 → D moves **down** | `A B gap D E` — reads as "lands before D" |
The rendered gap inverts across exactly the boundary that advances the slot, so the card settles
into a position the drop was never going to write, and on release it "goes back to its point". The
band is half a card tall and exists at every card in every lane — which is also why dragging into
the region above the pick-up point felt right and the rest did not.
- [x] `noSortingStrategy` unconditionally in [TaskKanbanColumn.tsx](apps/mail/modules/userTasks/components/TaskKanbanColumn.tsx); `sortingStrategy(reordering)` and the `verticalListSortingStrategy` import are gone. The `SortableContext` stays — `useSortable` needs one to register a drag SOURCE — but it no longer arranges anything.
- [x] `reordering` on the column is now chrome only (the whole-lane cover, the tint); the parting is the board's.
- [x] Extract `arrangeForDrag(columns, draggingId, placement)` — the pure arrangement, exported from [TaskKanbanBoard.tsx](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) — and have BOTH `displayColumns` and `handleDragEnd` derive from it, so the render and the write cannot be different answers.
- [x] `overLaneRef` / `appliedPlacementRef` hold the drag's actual state, written synchronously by the pointer listener. Phase 9 throttled the whole resolution to a frame; only the *render* is throttled now, because a release landing between the final pointer event and its frame planned against the position before last.
- [x] `reorder` at drop time is `appliedPlacementRef.current !== null` — a placement is written exactly when one is being shown, rather than re-deriving the condition from state that may not have committed.
**Known cost:** cards jump between slots rather than sliding, since the only displacement left is
DOM order. Worth it — an animation that lies about where the card will land is worse than none. A
FLIP pass over the lane would restore the motion honestly if it is missed.
**Tests:**
- [x] `apps/mail/tests/modules/userTasks/taskKanbanSorting.test.ts` rewritten: the lane displaces nothing whatever dnd-kit reports the card is over, the strategy is one stable constant, and `arrangeForDrag` places / lifts / clamps — ending with the drop planning a `sortOrder` between the neighbours the arrangement shows, which is the invariant the two systems were breaking.
- [x] `timeout 600 pnpm --filter @zero/mail exec jest tests/modules/userTasks` — 251 passed.
- [x] `timeout 900 pnpm --filter @zero/mail run types`.