TaskGroupsPage.tsx22.5 KBView on GitHub 'use client';
/**
* TaskGroupsPage — `/tasks/groups`, the full configuration surface for task groups.
*
* Groups are the categorical axis on a task (see TASK_AXES_DESIGN.md): the bucket the user files
* work into, and — once group names drive Gmail labels — the name that ends up on the draft. That
* makes every field on the row worth editing, so this page exposes all of them rather than the
* name/colour/rule subset the dialog it replaces offered:
*
* name · colour · icon · routing criteria · agent visibility · overdue policy · position
*
* Order is drag-to-reorder (plus keyboard move up/down), writing through `taskGroups.reorderGroups`
* exactly like the Display popover's group-order list: the cache is rewritten before the round trip
* so the list, board and sidebar all move together instead of waiting on a refetch.
*
* Misc is virtual — the server synthesizes it with `id: null` and pins it last — so it renders as a
* static, read-only card. It cannot be renamed, reordered or deleted.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { useTRPC } from '@/providers/query-provider';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { CEDAR_COLORS, ColorPickerPopover } from '@/components/ui/SexyColourPicker';
import { TASK_GROUP_ICON_MAP, taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { reorderCachedGroups, listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { cn } from '@/lib/utils';
const DEFAULT_COLOR = CEDAR_COLORS.dark[5]; // blue
const DEFAULT_ICON = 'ListTodo';
/** Mirrors TASK_GROUP_OVERDUE_MODES in apps/server/src/db/aop-schema.ts. */
const OVERDUE_MODES = ['nag', 'auto-complete', 'ignore'] as const;
type OverdueMode = (typeof OVERDUE_MODES)[number];
const OVERDUE_MODE_LABELS: Record<OverdueMode, string> = {
nag: 'Keep surfacing it',
'auto-complete': 'Auto-complete it',
ignore: 'Never mark it overdue',
};
const OVERDUE_MODE_HINTS: Record<OverdueMode, string> = {
nag: 'Stays put and is shown as overdue once the grace window passes.',
'auto-complete': 'Marked done (recoverably) once the grace window passes.',
ignore: 'Never shown as overdue, however old it gets.',
};
interface OverduePolicy {
mode: OverdueMode;
afterDays: number;
agentMayReschedule: boolean;
}
/** What a group falls back to the moment the user touches its overdue controls. */
const DEFAULT_OVERDUE_POLICY: OverduePolicy = {
mode: 'nag',
afterDays: 3,
agentMayReschedule: true,
};
interface TaskGroupRow {
id: string | null;
name: string;
color: string | null;
icon: string | null;
position: number;
routingCriteria: string | null;
overduePolicy: OverduePolicy | null;
agentVisible: boolean;
openTaskCount: number;
isMisc: boolean;
}
/** The subset of the update input this page writes. `null` clears a nullable field. */
interface GroupPatch {
name?: string;
color?: string;
icon?: string;
routingCriteria?: string | null;
agentVisible?: boolean;
overduePolicy?: OverduePolicy | null;
}
/** Icon picker over the registered lucide names — the value stored in `task_groups.icon`. */
function IconPickerPopover({
value,
color,
onSelect,
}: {
value: string | null;
color: string | null;
onSelect: (icon: string) => void;
}) {
const [open, setOpen] = useState(false);
const Current = taskGroupIcon(value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Choose icon"
className="hover:bg-sunken flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors"
>
<Current className="h-4 w-4" style={color ? { color } : undefined} />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<p className="text-muted-foreground px-1 pb-1.5 text-xs">Icon</p>
<div className="grid grid-cols-5 gap-1">
{Object.keys(TASK_GROUP_ICON_MAP).map((name) => {
const Icon = TASK_GROUP_ICON_MAP[name];
const selected = (value ?? DEFAULT_ICON) === name;
return (
<button
key=[redacted]
type="button"
aria-label={name}
onClick={() => {
onSelect(name);
setOpen(false);
}}
className={cn(
'hover:bg-sunken flex size-8 cursor-pointer items-center justify-center rounded-md border transition-colors',
selected ? 'border-border' : 'border-transparent',
)}
>
<Icon className="h-4 w-4" style={color ? { color } : undefined} />
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
/** A labelled settings row inside a group card. */
function SettingRow({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3 py-1.5">
<div className="min-w-0">
<p className="text-sm">{label}</p>
{hint && <p className="text-muted-foreground text-xs">{hint}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">{children}</div>
</div>
);
}
interface GroupCardProps {
group: TaskGroupRow & { id: string };
index: number;
total: number;
onPatch: (patch: GroupPatch) => void;
onMove: (direction: -1 | 1) => void;
onDelete: () => void;
}
/** One editable group. Text fields commit on blur; every other control commits immediately. */
function GroupCard({ group, index, total, onPatch, onMove, onDelete }: GroupCardProps) {
const [name, setName] = useState(group.name);
const [criteria, setCriteria] = useState(group.routingCriteria ?? '');
const policy = group.overduePolicy ?? DEFAULT_OVERDUE_POLICY;
const [afterDays, setAfterDays] = useState(String(policy.afterDays));
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: group.id,
animateLayoutChanges: () => true,
});
const patchPolicy = (patch: Partial<OverduePolicy>) =>
onPatch({ overduePolicy: { ...policy, ...patch } });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition: transition || 'transform 200ms ease',
zIndex: isDragging ? 1 : undefined,
}}
className={cn(
'border-border/60 bg-raised rounded-lg border px-3 py-2.5',
isDragging && 'ring-primary/20 ring-2',
)}
>
{/* Header — order, identity, count, delete. */}
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`Drag ${group.name}`}
className="text-muted-foreground/60 hover:text-foreground shrink-0 cursor-grab transition-colors active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="text-muted-foreground w-4 shrink-0 text-right text-xs tabular-nums">
{index + 1}.
</span>
<ColorPickerPopover
value={group.color ?? DEFAULT_COLOR}
onColorSelect={(color) => onPatch({ color })}
dotClassName="h-3.5 w-3.5 shrink-0"
/>
<IconPickerPopover
value={group.icon}
color={group.color}
onSelect={(icon) => onPatch({ icon })}
/>
<Input
aria-label="Group name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
const next = name.trim();
if (next && next !== group.name) onPatch({ name: next });
else setName(group.name);
}}
className="h-8 min-w-0 flex-1 border-none bg-transparent px-1 text-sm font-medium shadow-none"
/>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{group.openTaskCount} open
</span>
<button
type="button"
aria-label={`Move ${group.name} up`}
disabled={index === 0}
onClick={() => onMove(-1)}
className="text-muted-foreground/60 hover:text-foreground shrink-0 cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
aria-label={`Move ${group.name} down`}
disabled={index === total - 1}
onClick={() => onMove(1)}
className="text-muted-foreground/60 hover:text-foreground shrink-0 cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
aria-label={`Delete ${group.name}`}
onClick={onDelete}
className="text-muted-foreground/60 hover:text-destructive shrink-0 cursor-pointer transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* Routing criteria — the natural-language rule the AI router reads. */}
<div className="mt-2 flex flex-col gap-1 pl-6">
<label className="text-muted-foreground text-xs" htmlFor={`criteria-${group.id}`}>
Auto-file when… <span className="opacity-60">(the agent reads this)</span>
</label>
<Textarea
id={`criteria-${group.id}`}
value={criteria}
onChange={(e) => setCriteria(e.target.value)}
onBlur={() => {
const next = criteria.trim();
if (next !== (group.routingCriteria ?? '')) {
onPatch({ routingCriteria: next || null });
}
}}
placeholder="Anything about hiring, candidates, or interviews"
className="min-h-[56px] resize-none text-sm"
/>
</div>
{/* Behaviour. */}
<div className="divide-border/40 mt-2 flex flex-col divide-y pl-6">
<SettingRow
label="Visible to the agent"
hint="Off keeps this lane out of the agent's default task list."
>
<Switch
aria-label={`Agent visibility for ${group.name}`}
checked={group.agentVisible}
onCheckedChange={(agentVisible) => onPatch({ agentVisible })}
/>
</SettingRow>
<SettingRow label="When a task here goes overdue" hint={OVERDUE_MODE_HINTS[policy.mode]}>
<Select
value={policy.mode}
onValueChange={(mode) => patchPolicy({ mode: mode as OverdueMode })}
>
<SelectTrigger size="sm"
aria-label={`Overdue policy for ${group.name}`}
className="w-44"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{OVERDUE_MODES.map((mode) => (
<SelectItem key=[redacted] value={mode} className="text-xs">
{OVERDUE_MODE_LABELS[mode]}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow label="Grace window" hint="Days past the due date before the policy applies.">
<Input
aria-label={`Grace window for ${group.name}`}
type="number"
min={0}
value={afterDays}
onChange={(e) => setAfterDays(e.target.value)}
onBlur={() => {
const next = Math.max(0, Math.trunc(Number(afterDays) || 0));
setAfterDays(String(next));
if (next !== policy.afterDays) patchPolicy({ afterDays: next });
}}
className="h-7 w-20 text-xs"
/>
</SettingRow>
<SettingRow
label="Agent may reschedule"
hint="Lets the agent push an overdue task out instead of leaving it."
>
<Switch
aria-label={`Agent may reschedule ${group.name}`}
checked={policy.agentMayReschedule}
onCheckedChange={(agentMayReschedule) => patchPolicy({ agentMayReschedule })}
/>
</SettingRow>
</div>
</div>
);
}
/** The virtual Misc lane: synthesized by the server, so nothing here is editable. */
function MiscCard({ group, index }: { group: TaskGroupRow; index: number }) {
const Icon = taskGroupIcon(group.icon);
return (
<div className="border-border/60 bg-muted/20 rounded-lg border border-dashed px-3 py-2.5 opacity-80">
<div className="flex items-center gap-2">
<span className="w-4 shrink-0" />
<span className="text-muted-foreground w-4 shrink-0 text-right text-xs tabular-nums">
{index + 1}.
</span>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate px-1 text-sm font-medium">{group.name}</span>
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{group.openTaskCount} open
</span>
<span className="text-muted-foreground shrink-0 text-xs">Always last</span>
</div>
<p className="text-muted-foreground mt-1.5 pl-6 text-xs">
Misc isn't a real group — it's where a task sits until it's filed. It
can't be renamed, reordered or deleted.
</p>
</div>
);
}
/** The inline "new group" form. */
function CreateGroupForm({
onCreate,
isPending,
}: {
onCreate: (input: { name: string; color: string; icon: string; routingCriteria?: string }) => void;
isPending: boolean;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [color, setColor] = useState<string>(DEFAULT_COLOR);
const [icon, setIcon] = useState<string>(DEFAULT_ICON);
const [criteria, setCriteria] = useState('');
const submit = () => {
const trimmed = name.trim();
if (!trimmed) return;
onCreate({ name: trimmed, color, icon, routingCriteria: criteria.trim() || undefined });
setName('');
setCriteria('');
setColor(DEFAULT_COLOR);
setIcon(DEFAULT_ICON);
setOpen(false);
};
if (!open) {
return (
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New group
</Button>
);
}
return (
<div className="border-border/70 flex w-full flex-col gap-2 rounded-lg border border-dashed p-2.5">
<div className="flex items-center gap-2">
<ColorPickerPopover value={color} onColorSelect={setColor} dotClassName="h-3.5 w-3.5" />
<IconPickerPopover value={icon} color={color} onSelect={setIcon} />
<Input
autoFocus
aria-label="New group name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
placeholder="Recruiting"
className="h-8 min-w-0 flex-1 text-sm"
/>
</div>
<Textarea
aria-label="New group auto-file rule"
value={criteria}
onChange={(e) => setCriteria(e.target.value)}
placeholder="Auto-file when… anything about hiring, candidates, or interviews"
className="min-h-[48px] resize-none text-sm"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button size="sm" onClick={submit} disabled={!name.trim() || isPending}>
{isPending ? 'Creating…' : 'Create'}
</Button>
</div>
</div>
);
}
export function TaskGroupsPage() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const groupsKey=[redacted];
const { data } = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
const invalidate = () => void queryClient.invalidateQueries({ queryKey=[redacted] });
const { mutate: createGroup, isPending: creating } = useMutation(
trpc.taskGroups.createGroup.mutationOptions({
onSuccess: () => {
invalidate();
},
onError: (err) => toast.error(`Couldn't create: ${err.message}`),
}),
);
const { mutate: updateGroup } = useMutation(
trpc.taskGroups.updateGroup.mutationOptions({
onSuccess: invalidate,
onError: (err) => toast.error(`Couldn't update: ${err.message}`),
}),
);
const { mutate: deleteGroup } = useMutation(
trpc.taskGroups.deleteGroup.mutationOptions({
onSuccess: () => {
invalidate();
void queryClient.invalidateQueries({ queryKey=[redacted] });
},
onError: (err) => toast.error(`Couldn't delete: ${err.message}`),
}),
);
const { mutate: reorderGroups } = useMutation(trpc.taskGroups.reorderGroups.mutationOptions());
const rows = useMemo<TaskGroupRow[]>(() => {
const raw = data?.groups ?? [];
return raw
.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
icon: g.icon,
position: g.position,
routingCriteria: g.routingCriteria,
overduePolicy: g.overduePolicy,
agentVisible: g.agentVisible,
openTaskCount: g.openTaskCount,
isMisc: g.isMisc,
}))
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name));
}, [data]);
const editable = useMemo(
() => rows.filter((g): g is TaskGroupRow & { id: string } => !g.isMisc && !!g.id),
[rows],
);
const misc = rows.find((g) => g.isMisc);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
// Write the new order into the cache before the round trip, so the list, board and sidebar move
// together instead of snapping back if a refetch loses the race (see TaskGroupOrderPopover).
const applyOrder = (orderedGroupIds: string[]) => {
queryClient.setQueriesData({ queryKey=[redacted] }, (cached: unknown) =>
reorderCachedGroups(cached, orderedGroupIds),
);
reorderGroups(
{ orderedGroupIds },
{ onSettled: () => void queryClient.invalidateQueries({ queryKey=[redacted] }) },
);
};
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= editable.length) return;
applyOrder(arrayMove(editable, index, target).map((g) => g.id));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = editable.findIndex((g) => g.id === active.id);
const newIndex = editable.findIndex((g) => g.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
applyOrder(arrayMove(editable, oldIndex, newIndex).map((g) => g.id));
};
return (
<div className="flex flex-col gap-3 pb-10 pl-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight">Task groups</h2>
<p className="text-muted-foreground text-sm">
The buckets your tasks are filed into. Each group's rule is what the router reads
when a new task arrives; the order here is the section order on the list and the column
order on the board.
</p>
</div>
<CreateGroupForm onCreate={(input) => createGroup(input)} isPending={creating} />
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={editable.map((g) => g.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{editable.map((group, index) => (
<GroupCard
key=[redacted]
group={group}
index={index}
total={editable.length}
onPatch={(patch) => updateGroup({ groupId: group.id, ...patch })}
onMove={(direction) => move(index, direction)}
onDelete={() => setPendingDelete({ id: group.id, name: group.name })}
/>
))}
</div>
</SortableContext>
</DndContext>
{editable.length === 0 && (
<p className="text-muted-foreground border-border/60 rounded-lg border border-dashed px-4 py-8 text-center text-sm">
No task groups yet — everything lands in Misc.
</p>
)}
{misc && <MiscCard group={misc} index={editable.length} />}
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete “{pendingDelete?.name}”?</AlertDialogTitle>
<AlertDialogDescription>
No tasks are deleted. Every task in this group is re-filed into Misc. This
can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
onClick={() => {
if (pendingDelete) deleteGroup({ groupId: pendingDelete.id });
setPendingDelete(null);
}}
>
Delete group
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}