use-setup-task-groups.ts4.2 KBView on GitHub import { useCallback, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useTRPC } from '@/providers/query-provider';
import { listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { CEDAR_COLORS } from '@/components/ui/SexyColourPicker';
export interface StarterGroup {
name: string;
/** Lucide icon name, resolved through TASK_GROUP_ICON_MAP. */
icon: string;
color: string;
/** Natural-language rule the AI router reads to file a task into this lane. */
routingCriteria: string;
blurb: string;
}
/**
* The lanes Cedar's own agents already produce work for.
*
* They match the agent task types the drafting pipeline emits, so a user who
* takes all five gets a board where every draft Cedar writes has somewhere to
* land — rather than a Misc pile they have to sort by hand later.
*/
export const STARTER_TASK_GROUPS: StarterGroup[] = [
{
name: 'Follow-ups',
icon: 'Send',
color: CEDAR_COLORS.dark[5]!,
routingCriteria: 'Sent threads that have gone quiet and need a nudge.',
blurb: 'Threads you sent that nobody answered.',
},
{
name: 'Replies',
icon: 'Reply',
color: CEDAR_COLORS.dark[2]!,
routingCriteria: 'Inbound emails waiting on a response from me.',
blurb: 'Inbound mail still waiting on you.',
},
{
name: 'Post-meeting',
icon: 'CalendarCheck',
color: CEDAR_COLORS.dark[3]!,
routingCriteria: 'Recap and next steps owed after a call has wrapped.',
blurb: 'Recaps and next steps after a call.',
},
{
name: 'Meeting prep',
icon: 'CalendarClock',
color: CEDAR_COLORS.dark[1]!,
routingCriteria: 'Research and prep due before an upcoming external meeting.',
blurb: 'Research due before the next call.',
},
{
name: 'Deals',
icon: 'Trophy',
color: CEDAR_COLORS.dark[0]!,
routingCriteria: 'Anything tied to an open deal moving through the pipeline.',
blurb: 'Work attached to an open deal.',
},
];
interface CachedGroupsPayload {
groups: { id: string | null; name: string }[];
}
/**
* Create the starter task groups, one click each.
*
* The write goes through `createGroup` and the list is refetched after — no
* optimistic row, because the group's real id comes from the server and every
* other surface keys off it. The button holds its own pending state instead, so
* the click still feels answered.
*/
export function useSetupTaskGroups() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [pending, setPending] = useState<string[]>([]);
const groupsQuery = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));
const existingNames = useMemo(
() =>
new Set(
((groupsQuery.data as CachedGroupsPayload | undefined)?.groups ?? [])
.filter((group) => group.id !== null)
.map((group) => group.name.toLowerCase()),
),
[groupsQuery.data],
);
const { mutateAsync: createGroup } = useMutation(
trpc.taskGroups.createGroup.mutationOptions({
onSettled: () =>
queryClient.invalidateQueries({ queryKey=[redacted] }),
}),
);
const has = useCallback(
(name: string) => existingNames.has(name.toLowerCase()) || pending.includes(name),
[existingNames, pending],
);
const create = useCallback(
async (group: { name: string; icon?: string; color?: string; routingCriteria?: string }) => {
const name = group.name.trim();
if (!name || has(name)) return;
setPending((current) => [...current, name]);
try {
await createGroup({
name,
icon: group.icon,
color: group.color,
routingCriteria: group.routingCriteria,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : `Couldn't create ${name}`);
} finally {
setPending((current) => current.filter((n) => n !== name));
}
},
[createGroup, has],
);
return {
starters: STARTER_TASK_GROUPS,
groups: (groupsQuery.data as CachedGroupsPayload | undefined)?.groups ?? [],
isLoading: groupsQuery.isPending,
has,
create,
};
}