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;
},
);
}