resolve-tasks-on-send.ts12.1 KBView on GitHub
/**
 * Close the tasks a send just satisfied — on the client, at click time, everywhere at once.
 *
 * ## The bug this exists for
 *
 * Sending a Cedar-drafted email closes its task server-side inside the send request
 * (`completeTaskByDraftId`, fired from `mail.send` / `drafts.send` / `slack.sendMessage`) and
 * again from `handleExecuteFromClientSend`, which sweeps the thread and streams a `taskCompleted`
 * back. Both are correct and both are LATE: the sweep only starts once the client fires its
 * follow-up call, and the stream event lands seconds after the composer has already closed. In
 * between, the user is looking at a sidebar that still lists the task they just did.
 *
 * The stream event also did not reach every surface. `taskCompletedProcessor` writes the Cedar
 * store, but the empty chat's Tasks card (ThreadContextCards) renders straight off the
 * `crm.getConversation` QUERY — nothing in the store path touches it, so its "3 tasks" count sat
 * unchanged until something else happened to invalidate that query.
 *
 * ## What this does instead
 *
 * One entry point, called by every surface that sends something. It asks
 * `tasksSatisfiedBySend` — a mirror of the server's own completion predicates — which open tasks
 * this send closes, then applies that answer to EVERY place the client keeps tasks:
 *
 *   1. the pending-resolution mask, so no in-flight refetch can put the row back;
 *   2. `userTasksSlice.tasks` + the date buckets (task list, kanban, execution rail);
 *   3. `conversations[id].data.userTasks` (the conversation timeline and Strategic Overview);
 *   4. the React Query caches, via task-status-cache.ts.
 *
 * (3) and (4) are separate on purpose: they are separate reads. Patching only the store is what
 * left the Tasks card stale, and patching only the cache would leave the timeline stale.
 *
 * ## Why it is safe to be early
 *
 * `tasksSatisfiedBySend` closes exactly the rows the server is about to close and nothing else,
 * so the mask below is not a lie waiting to be found out — it is the same answer, sooner. It is
 * dropped after `SETTLE_MS` once the task queries have been refetched, at which point server
 * truth speaks for itself. If the send is undone or fails, `rollback()` puts everything back.
 *
 * Deliberately a plain module function rather than a hook: the call sites are mutation callbacks
 * and undo-send continuations, neither of which can host one.
 */

import {
  clearTaskResolutionPending,
  markTaskResolutionPending,
} from '@/modules/userTasks/lib/pending-task-resolutions';
import {
  tasksSatisfiedBySend,
  type SendArtifact,
  type SendMatchableTask,
} from '@/modules/userTasks/lib/task-send-match';
import {
  adjustCachedOpenTaskCounts,
  cachedTaskRows,
  setCachedTaskStatus,
} from '@/modules/userTasks/lib/task-status-cache';
import { getBrowserQueryClient } from '@/lib/browser-query-client';
import { useCedarStore } from '@/modules/store';

export type { SendArtifact } from '@/modules/userTasks/lib/task-send-match';

/**
 * How long the optimistic close is held before server truth is asked for again.
 *
 * Long enough to cover the slowest of the server paths: `mail.send`'s fire-and-forget
 * `completeTaskByDraftId`, and `handleExecuteFromClientSend`, which the client only starts after
 * the send resolves and which fetches a conversation and an AOP before it gets to the sweep. Too
 * short and the refetch lands while the row is still `todo` and the task blinks back; the cost of
 * too long is only that a task the server declined to close stays hidden a few extra seconds.
 */
const SETTLE_MS = 20_000;

/** The handle a caller keeps so an undone or failed send can be put back. */
export interface SendResolutionHandle {
  /** Ids closed by this send. Empty when the send satisfied nothing — the common case. */
  taskIds: string[];
  /** Restore every task this call closed. Safe to call twice; a no-op after the settle. */
  rollback: () => void;
}

const NOOP_HANDLE: SendResolutionHandle = { taskIds: [], rollback: () => {} };

/**
 * Every task the browser currently holds, from all four sources, first copy per id wins.
 *
 * All four are searched rather than just the slice because the surfaces do not agree on what
 * they have loaded: a task can sit in the conversation query with the slice never having listed
 * it (you opened one thread and never visited /tasks), or the reverse. Matching against the union
 * is what makes this work across every task source instead of only the one on screen.
 */
function collectCandidateTasks(): SendMatchableTask[] {
  const byId = new Map<string, SendMatchableTask>();
  const add = (task: unknown) => {
    const row = task as SendMatchableTask | null;
    if (row?.id && !byId.has(row.id)) byId.set(row.id, row);
  };

  const state = useCedarStore.getState();
  Object.values(state.tasks ?? {}).forEach(add);
  Object.values(state.conversations ?? {}).forEach((entry) =>
    (entry?.data?.userTasks ?? []).forEach(add),
  );
  cachedTaskRows().forEach(add);

  return [...byId.values()];
}

/** Flip a task's status in every store and cache that holds a copy of it. */
function writeStatus(taskIds: ReadonlySet<string>, status: 'todo' | 'done'): void {
  const done = status === 'done';
  const state = useCedarStore.getState();

  // 1. The slice, which every task list / kanban / execution-rail surface renders from.
  for (const taskId of taskIds) {
    if (!state.tasks?.[taskId]) continue;
    state.updateTask(taskId, {
      status,
      completedAt: done ? new Date() : null,
      updatedAt: new Date(),
    });
  }

  // 2. The date buckets. `useTodoTasks` filters on status, but the execution rail reads the
  //    bucket lists directly, so a closed task left in a bucket keeps its slot on the rail.
  //    One-way: rollback re-hydrates the buckets from the caller's snapshot instead, because the
  //    original position cannot be recovered from the task alone.
  if (done) {
    const buckets = useCedarStore.getState().taskIdsByDateKey;
    let changed = false;
    const next = { ...buckets };
    for (const key of Object.keys(next) as Array<keyof typeof next>) {
      const ids = next[key];
      if (!ids?.some((id) => taskIds.has(id))) continue;
      next[key] = ids.filter((id) => !taskIds.has(id));
      changed = true;
    }
    if (changed) useCedarStore.getState().setTaskIdsByDateKey(next);
  }

  // 3. The conversation store, which the timeline and Strategic Overview render from.
  const conversationPatch: Record<string, (typeof state.conversations)[string]['data']> = {};
  for (const [conversationId, entry] of Object.entries(state.conversations ?? {})) {
    const userTasks = entry?.data?.userTasks;
    if (!userTasks?.some((t) => taskIds.has(t.id))) continue;
    conversationPatch[conversationId] = {
      ...entry.data,
      userTasks: userTasks.map((t) => (taskIds.has(t.id) ? { ...t, status } : t)),
    } as (typeof state.conversations)[string]['data'];
  }
  if (Object.keys(conversationPatch).length > 0) state.setConversations(conversationPatch);

  // 4. The React Query caches. ThreadContextCards' Tasks card reads `crm.getConversation`
  //    directly and nothing above reaches it — this is the write that makes the count drop.
  setCachedTaskStatus(taskIds, status);
}

/**
 * Put closed tasks back on the rail at the index they were removed from.
 *
 * Re-inserting by index rather than appending because the buckets carry the user's manual order —
 * a restored task that lands at the bottom of "Today" reads as a different task.
 */
function restoreDateBucketSlots(
  slotsByTask: ReadonlyMap<string, Array<{ key=[redacted]; index: number }>>,
): void {
  if (slotsByTask.size === 0) return;

  const buckets = { ...useCedarStore.getState().taskIdsByDateKey } as Record<string, string[]>;
  let changed = false;
  for (const [taskId, slots] of slotsByTask) {
    for (const { key, index } of slots) {
      const ids = buckets[key] ?? [];
      if (ids.includes(taskId)) continue;
      const next = [...ids];
      next.splice(Math.min(index, next.length), 0, taskId);
      buckets[key] = next;
      changed = true;
    }
  }
  if (changed) {
    useCedarStore.getState().setTaskIdsByDateKey(buckets as never);
  }
}

/**
 * Resolve every open task this send satisfies, immediately.
 *
 * Call it at the moment the user commits to sending — the same instant the surface paints its
 * own optimistic message — not after the mutation resolves. The whole point is that the task
 * disappears on the click.
 *
 * Returns a handle whose `rollback()` undoes the lot; wire it to the same undo / error paths that
 * roll back the optimistic message, so the only states a user ever sees are "sent, task closed"
 * and "not sent, task open".
 */
export function resolveTasksForSend(artifact: SendArtifact): SendResolutionHandle {
  if (!artifact.draftId && !artifact.threadId && !artifact.taskIds?.length) return NOOP_HANDLE;

  let candidates: SendMatchableTask[];
  try {
    candidates = collectCandidateTasks();
  } catch (error) {
    // A send must never fail because the optimistic pass did. The server still closes the task.
    console.error('[resolveTasksForSend] Failed to read task sources:', error);
    return NOOP_HANDLE;
  }

  // Ids the caller named are closed whether or not this browser holds the row: a teammate's task
  // on a shared conversation is never in the (own-tasks-only) slice, and the surface that sends
  // it fetched it by id. Masking it anyway means the answer is already there if the row arrives
  // from a refetch before the server write lands.
  const idSet = new Set([
    ...tasksSatisfiedBySend(candidates, artifact),
    ...(artifact.taskIds ?? []),
  ]);
  const taskIds = [...idSet];
  if (taskIds.length === 0) return NOOP_HANDLE;

  const closedPerConversation = new Map<string, number>();
  for (const task of candidates) {
    if (!idSet.has(task.id) || !task.conversationId) continue;
    closedPerConversation.set(
      task.conversationId,
      (closedPerConversation.get(task.conversationId) ?? 0) + 1,
    );
  }

  // Where each closed task sat on the rail, so rollback can put it back in the same slot.
  // Recorded per task rather than as a snapshot of the whole map: restoring a whole snapshot
  // would also revert any unrelated bucket edit made during the undo window.
  const savedBucketSlots = new Map<string, Array<{ key=[redacted]; index: number }>>();
  const bucketsBefore = useCedarStore.getState().taskIdsByDateKey;
  for (const [key, ids] of Object.entries(bucketsBefore) as Array<[string, string[] | undefined]>) {
    ids?.forEach((id, index) => {
      if (!idSet.has(id)) return;
      const slots = savedBucketSlots.get(id) ?? [];
      slots.push({ key, index });
      savedBucketSlots.set(id, slots);
    });
  }

  // The mask FIRST: everything below can trigger a refetch, and the mask is what stops the
  // response re-hydrating a row we are in the middle of closing.
  for (const taskId of taskIds) markTaskResolutionPending(taskId, 'done');
  writeStatus(idSet, 'done');
  adjustCachedOpenTaskCounts(closedPerConversation, -1);

  let settled = false;
  const settleTimer = setTimeout(() => {
    settled = true;
    const queryClient = getBrowserQueryClient();
    const refreshed = queryClient
      ? Promise.all([
          queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] }),
          queryClient.invalidateQueries({ queryKey: [['crm', 'getConversation']] }),
          queryClient.invalidateQueries({ queryKey: [['crm', 'listConversations']] }),
        ])
      : Promise.resolve();

    // Drop the mask only once those refetches have settled. Clearing it first would let the
    // in-flight response — still carrying `todo` — put every row straight back.
    void refreshed
      .catch(() => undefined)
      .finally(() => taskIds.forEach(clearTaskResolutionPending));
  }, SETTLE_MS);

  return {
    taskIds,
    rollback: () => {
      if (settled) return;
      settled = true;
      clearTimeout(settleTimer);
      // Mask first again, in the other direction: it would otherwise re-hide the rows being
      // restored the moment anything re-reads them.
      taskIds.forEach(clearTaskResolutionPending);
      writeStatus(idSet, 'todo');
      restoreDateBucketSlots(savedBucketSlots);
      adjustCachedOpenTaskCounts(closedPerConversation, 1);
    },
  };
}