task-status-cache.ts5.2 KBView on GitHub
/**
 * Task rows inside the React Query cache — reading them, and writing a status across all of them.
 *
 * Three of the four places the browser keeps tasks are Zustand stores; the fourth is the query
 * cache, and it is the one that kept getting missed. The empty chat's Tasks card
 * (ThreadContextCards) renders straight off `crm.getConversation`, so a completion applied only
 * to the store — which is all `taskCompletedProcessor` could do — left its "3 tasks" count
 * standing until something unrelated invalidated that query.
 *
 * Deliberately dependency-free apart from `getBrowserQueryClient` (whose only import is a type),
 * for the same reason that module is: the Cedar store's SSE response processors need this, and
 * the store's own world imports the provider that owns the QueryClient. Anything reaching back
 * into `@/modules/store` from here would close that cycle — so nothing here does, and the
 * store-side writes live in resolve-tasks-on-send.ts instead.
 */

import { getBrowserQueryClient } from '@/lib/browser-query-client';

/** A task row as it sits in a cached response — every task shape has at least these. */
export interface CachedTaskRow {
  id?: string;
  status?: string;
}

/** The cached responses that carry task rows, by tRPC path. */
const TASK_BEARING_QUERIES = [
  'crm.getConversation',
  'userTasks.listUserTasks',
  'userTasks.getTaskById',
] as const;

/** tRPC's tanstack keys are `[[...path], {input,type}]` — the dotted path, for cache matching. */
export function queryPath(queryKey=[redacted] unknown[]): string {
  const head = queryKey[0];
  return Array.isArray(head) ? head.join('.') : '';
}

type TaskBearingData = {
  userTasks?: CachedTaskRow[];
  tasks?: CachedTaskRow[];
  task?: CachedTaskRow | null;
};

/** Every task row currently sitting in the query cache, in cache order. */
export function cachedTaskRows(): CachedTaskRow[] {
  const queryClient = getBrowserQueryClient();
  if (!queryClient) return [];

  const rows: CachedTaskRow[] = [];
  for (const query of queryClient.getQueryCache().getAll()) {
    const path = queryPath(query.queryKey);
    const data = query.state.data as TaskBearingData | undefined;
    if (!data) continue;
    if (path === 'crm.getConversation' && data.userTasks) rows.push(...data.userTasks);
    else if (path === 'userTasks.listUserTasks' && data.tasks) rows.push(...data.tasks);
    else if (path === 'userTasks.getTaskById' && data.task) rows.push(data.task);
  }
  return rows;
}

/**
 * Set `status` on every cached copy of `taskIds`.
 *
 * Rewrites rather than removes: the conversation checklist and the Tasks card both filter to
 * `todo`, so a `done` row is hidden either way, and the timeline's completion animation renders
 * FROM the `done` row and needs it to survive. Same reasoning as `applyPendingTaskResolutions`.
 */
export function setCachedTaskStatus(taskIds: ReadonlySet<string>, status: 'todo' | 'done'): void {
  const queryClient = getBrowserQueryClient();
  if (!queryClient || taskIds.size === 0) return;

  const patch = <T extends CachedTaskRow>(row: T): T =>
    row?.id && taskIds.has(row.id) ? { ...row, status } : row;

  queryClient.setQueriesData<unknown>(
    {
      predicate: (query) =>
        (TASK_BEARING_QUERIES as readonly string[]).includes(queryPath(query.queryKey)),
    },
    (old: unknown) => {
      const data = old as TaskBearingData | undefined;
      if (!data) return old;
      if (data.userTasks) return { ...data, userTasks: data.userTasks.map(patch) };
      if (data.tasks) return { ...data, tasks: data.tasks.map(patch) };
      if (data.task?.id && taskIds.has(data.task.id)) {
        return { ...data, task: { ...data.task, status } };
      }
      return old;
    },
  );
}

/**
 * Move the CRM list's open-task counters by `delta` per conversation.
 *
 * `crm.listConversations` sends a count rather than the rows, so the sidebar's open-task dot
 * cannot be patched by status like everything else — it has to be moved arithmetically. Floored
 * at zero: the count and the rows are fetched by different queries at different times, so they
 * can legitimately disagree, and a negative badge is a worse answer than a stale one.
 */
export function adjustCachedOpenTaskCounts(
  closedPerConversation: ReadonlyMap<string, number>,
  delta: 1 | -1,
): void {
  const queryClient = getBrowserQueryClient();
  if (!queryClient || closedPerConversation.size === 0) return;

  queryClient.setQueriesData<unknown>(
    { predicate: (query) => queryPath(query.queryKey) === 'crm.listConversations' },
    (old: unknown) => {
      const data = old as
        | { conversations?: Array<{ id?: string; openTaskCount?: number }> }
        | undefined;
      if (!data?.conversations) return old;

      let changed = false;
      const conversations = data.conversations.map((conversation) => {
        const closed = conversation.id ? closedPerConversation.get(conversation.id) : undefined;
        if (!closed || typeof conversation.openTaskCount !== 'number') return conversation;
        changed = true;
        return {
          ...conversation,
          openTaskCount: Math.max(0, conversation.openTaskCount + delta * closed),
        };
      });
      return changed ? { ...data, conversations } : old;
    },
  );
}