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&apos;t a real group — it&apos;s where a task sits until it&apos;s filed. It
        can&apos;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&apos;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&apos;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>
  );
}