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