group-cache.ts3.7 KBView on GitHub
/**
 * Optimistic edits to the cached `taskGroups.listGroups` payload.
 *
 * Every surface that shows groups — the List, the Board, the sidebar nav, the order popover and
 * /tasks/groups — renders from this one query. Writing the change into the cache before the round
 * trip is what makes them all move together; without it the edit only lands after a refetch, and
 * that refetch can lose a race with an in-flight `listGroups` request issued before the mutation,
 * putting the stale value straight back.
 *
 * These take `unknown` deliberately. `listGroups` returns a union of the real-group row and the
 * synthesized virtual-Misc row (whose `color`/`icon`/`id` are `null` literals), so mapping over the
 * array inside a typed `setQueryData` updater widens those fields and stops type-checking against
 * the Misc variant. Going through `unknown` at the cache boundary — the same trick the CRM canvas
 * cache uses — keeps the helpers honest and makes them unit-testable without a query client.
 */

import { endOfToday } from '@/modules/userTasks/utils/task-filters';

/**
 * The input EVERY `listGroups` caller passes.
 *
 * It exists to be identical everywhere: the query key is derived from it, so a surface that passed
 * something else would sit on its own cache entry and the optimistic writes below — which patch one
 * payload — would only move half the app. `dueBefore` is where this browser's day ends, since the
 * server has no idea what timezone the user is in; it's a fixed instant, so the key is stable for
 * the whole day and rolls over at midnight, which is exactly when the counts should change.
 */
export function listGroupsInput(): { dueBefore: string } {
  return { dueBefore: new Date(endOfToday()).toISOString() };
}

/** The subset of a cached group row these helpers touch. Misc carries `id: null`. */
interface CachedGroup {
  id: string | null;
  name: string;
  color: string | null;
  icon: string | null;
  position: number;
}

/**
 * Apply a new group order to a cached `listGroups` payload.
 *
 * Both fields have to move: the List renders the array in the order it arrives, while the Board
 * sorts by `position`. Rewriting only one of them would reorder one surface and not the other.
 * Misc keeps its MAX_SAFE_INTEGER position and so stays last.
 */
export function reorderCachedGroups(data: unknown, orderedIds: string[]): unknown {
  const payload = data as { groups?: CachedGroup[] } | undefined;
  if (!payload?.groups) return data;

  const rank = new Map(orderedIds.map((id, index) => [id, index]));
  const groups = payload.groups
    .map((g) => (g.id !== null && rank.has(g.id) ? { ...g, position: rank.get(g.id)! } : g))
    .sort((a, b) => a.position - b.position);

  return { ...payload, groups };
}

/**
 * Patch one group's presentational fields in a cached `listGroups` payload.
 *
 * Only the fields a header shows — an inline rename/recolour on the board should repaint the column
 * under the cursor, not wait for the server. Misc (`id: null`) can never match, so it is inherently
 * safe from an edit. An absent key leaves that field alone; passing `color: null` clears it.
 */
export function patchCachedGroup(
  data: unknown,
  groupId: string,
  patch: { name?: string; color?: string | null; icon?: string | null },
): unknown {
  const payload = data as { groups?: CachedGroup[] } | undefined;
  if (!payload?.groups) return data;

  const groups = payload.groups.map((g) =>
    g.id === groupId
      ? {
          ...g,
          ...(patch.name !== undefined ? { name: patch.name } : {}),
          ...(patch.color !== undefined ? { color: patch.color } : {}),
          ...(patch.icon !== undefined ? { icon: patch.icon } : {}),
        }
      : g,
  );

  return { ...payload, groups };
}