NewTaskDialog.tsx24.1 KBView on GitHub
'use client';

/**
 * NewTaskDialog — creating a task, as one modal.
 *
 * Replaces the inline `NewTaskCard`, which dropped a card into the column and walked three
 * cmd+k steps (deal → description → date). One step at a time meant you could not see what
 * you had already chosen, could not go back, and could not skip the date — every task cost
 * three decisions whether or not it needed them.
 *
 * This is Linear's "New issue" modal: everything visible at once, title first, every property
 * an optional chip you may ignore. The geometry is measured off Linear's live DOM and written
 * down in `apps/mail/docs/new-task-modal-linear-spec.md` — read that before changing a number
 * here. Two deliberate departures from it:
 *   • Type sizes use the standard Tailwind scale (18/14/12) rather than Linear's 18/15/13,
 *     because arbitrary px text sizes are banned in this app.
 *   • The primary button is our `primary` token, not Linear's indigo. Copy the shape, keep
 *     the brand.
 */
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Command as Cmdk } from 'cmdk';
import { format, isToday, isTomorrow } from 'date-fns';
import { CalendarClock, CircleSlash, User, X } from 'lucide-react';
import { toast } from 'sonner';

import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command';
import { Breadcrumb } from '@/components/ui/breadcrumb';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { OptionPicker, PICKER_SURFACE_CLASS } from '@/components/ui/option-picker';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ConversationCompanyAvatar } from '@/modules/conversationsPage/components/ConversationCompanyAvatar';
import { createDateFuseInstance, generateDateSuggestions } from '@/modules/crm/utils/time';
import { useOpenTaskView } from '@/modules/userTasks/hooks/use-open-task-view';
import { taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';

/**
 * Dim the page behind the modal.
 *
 * Linear does not — it floats its modal on shadow alone. We do, because a task is composed
 * against a board you are still reading and the scrim is what says "answer this first".
 * Clicking the scrim dismisses; nothing is lost when it does (see `DRAFT_KEY`).
 * Flip to `false` for Linear's shadow-only treatment.
 */
const DIM_BACKDROP = true;

/**
 * A property chip. 24px pill, ring drawn as an `::after` box-shadow rather than a border so it
 * never affects layout — Linear's trick, see the spec.
 *
 * `bg-sunken` against the modal's `bg-popover` (raised). Linear's chips are the same white as
 * its modal and lean entirely on the ring, which works on one fixed light theme; ours has two,
 * and on the dark card an unfilled chip disappeared into it. Raised-on-base was the other
 * candidate and is the one that fails: #fffcf6 → #ffffff in light is invisible.
 */
const CHIP =
  'relative flex h-6 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-sunken pl-1.5 pr-2 ' +
  'text-xs font-medium text-muted-foreground transition-colors hover:text-foreground ' +
  'after:pointer-events-none after:absolute after:inset-0 after:rounded-full ' +
  'after:shadow-[0_0_0_0.5px_rgb(0_0_0/0.09),0_3px_6px_-2px_rgb(0_0_0/0.02),0_1px_1px_rgb(0_0_0/0.04)] ' +
  'dark:after:shadow-[0_0_0_0.5px_rgb(255_255_255/0.14)]';

/** A chip carrying a value reads as foreground text; an unset one stays muted. */
const CHIP_SET = 'text-foreground';

const PICKER_ITEM =
  'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm aria-selected:bg-sunken';

/**
 * The unsent draft, kept in localStorage.
 *
 * Clicking away from a half-written task must not cost anything, and must not cost a
 * "discard your changes?" prompt either — that dialog is the app asking the user to clean up
 * after a decision the app made. So dismissing just stores what is there; the next `c` opens
 * the modal exactly as it was left. Cleared once the task is actually created.
 */
const DRAFT_KEY=[redacted];

/**
 * How long to wait between closing one picker and opening the next in the Enter chain.
 *
 * Popovers animate in and out over 150ms. Opening the date picker on the next frame starts its
 * enter animation while the deal picker is still running its exit, and the two interfere —
 * the incoming layer sticks part-way through its fade and never reaches full opacity, so the
 * picker is open, focused, and all but invisible. Letting the outgoing one finish first is the
 * whole fix.
 */
const PICKER_HANDOFF_MS = 170;

interface StoredDraft {
  title: string;
  notes: string;
  deal: ChosenConversation | null;
  dueDate: { iso: string; label: string } | null;
  groupId: string | null;
}

function readDraft(): StoredDraft | null {
  try {
    const raw = window.localStorage.getItem(DRAFT_KEY);
    if (!raw) return null;
    const parsed: unknown = JSON.parse(raw);
    if (typeof parsed !== 'object' || parsed === null) return null;
    const d = parsed as Partial<StoredDraft>;
    if (typeof d.title !== 'string' || typeof d.notes !== 'string') return null;
    return {
      title: d.title,
      notes: d.notes,
      deal: d.deal ?? null,
      dueDate: d.dueDate ?? null,
      groupId: d.groupId ?? null,
    };
  } catch {
    // Private mode, disabled storage, or a shape from an older build — start clean.
    return null;
  }
}

function writeDraft(draft: StoredDraft | null) {
  try {
    if (draft === null) window.localStorage.removeItem(DRAFT_KEY);
    else window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
  } catch {
    // Storage unavailable — the draft simply doesn't survive, which is the old behaviour.
  }
}

/** How a chosen date reads on the chip. Matches the picker's own Today/Tomorrow wording. */
function formatDueLabel(date: Date): string {
  if (isToday(date)) return 'Today';
  if (isTomorrow(date)) return 'Tomorrow';
  return format(date, 'EEE, MMM d');
}

/** A draft worth restoring has something in it. */
function draftIsEmpty(d: StoredDraft): boolean {
  return !d.title.trim() && !d.notes.trim() && !d.deal && !d.dueDate;
}

interface ChosenConversation {
  id: string;
  label: string;
  /** The deal's `next_step_date` — when it is next due an update, and so the task's default. */
  nextStepDate: string | null;
}

interface NewTaskDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Pre-selected group — the column the modal was opened from. null for Misc. */
  taskGroupId: string | null;
  /** Called after a task is created, so the caller can refetch its list. */
  onCreated: () => void;
}

export function NewTaskDialog({
  open,
  onOpenChange,
  taskGroupId,
  onCreated,
}: NewTaskDialogProps) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const openTaskView = useOpenTaskView();

  const [title, setTitle] = useState('');
  const [notes, setNotes] = useState('');
  const [deal, setDeal] = useState<ChosenConversation | null>(null);
  const [dueDate, setDueDate] = useState<{ date: Date; label: string } | null>(null);
  const [groupId, setGroupId] = useState<string | null>(taskGroupId);
  const [dealPickerOpen, setDealPickerOpen] = useState(false);
  const [datePickerOpen, setDatePickerOpen] = useState(false);

  const titleRef = useRef<HTMLTextAreaElement>(null);
  const notesRef = useRef<HTMLTextAreaElement>(null);

  const create = useMutation(trpc.userTasks.createStandaloneTask.mutationOptions());
  const { data: groupsData } = useQuery({
    ...trpc.taskGroups.listGroups.queryOptions(listGroupsInput()),
    enabled: open,
  });
  const groups = groupsData?.groups ?? [];

  // Opening restores whatever was left behind last time; an empty (or absent) draft starts
  // clean, with the group seeded from the column the modal was opened from.
  useEffect(() => {
    if (!open) return;
    setDealPickerOpen(false);
    setDatePickerOpen(false);
    const draft = readDraft();
    if (draft && !draftIsEmpty(draft)) {
      setTitle(draft.title);
      setNotes(draft.notes);
      setDeal(draft.deal);
      setDueDate(
        draft.dueDate ? { date: new Date(draft.dueDate.iso), label: draft.dueDate.label } : null,
      );
      setGroupId(draft.groupId);
      return;
    }
    setTitle('');
    setNotes('');
    setDeal(null);
    setDueDate(null);
    setGroupId(taskGroupId);
  }, [open, taskGroupId]);

  // Every keystroke is saved, so no dismissal path — Escape, the close button, a click on the
  // scrim — needs its own handling, and none of them can ask the user to confirm a loss.
  useEffect(() => {
    if (!open) return;
    writeDraft({
      title,
      notes,
      deal,
      dueDate: dueDate ? { iso: dueDate.date.toISOString(), label: dueDate.label } : null,
      groupId,
    });
  }, [open, title, notes, deal, dueDate, groupId]);

  /**
   * Picking the deal is the middle of the Enter chain: title → deal → date.
   *
   * The task's due date defaults to the deal's own next step date, because a task about a deal
   * is due when the deal is due an update — asking again for a date the CRM already holds is
   * the kind of question software should answer for itself. The picker still opens on top of
   * it, so the default is a starting point rather than a decision made for the user.
   */
  const chooseDeal = (chosen: ChosenConversation | null) => {
    setDeal(chosen);
    if (chosen?.nextStepDate) {
      const date = new Date(chosen.nextStepDate);
      if (!Number.isNaN(date.getTime())) setDueDate({ date, label: formatDueLabel(date) });
    }
    setDealPickerOpen(false);
    if (chosen) window.setTimeout(() => setDatePickerOpen(true), PICKER_HANDOFF_MS);
  };

  /**
   * The last link in the chain. Once the date is answered the properties are done, so focus
   * lands in the description — the one field left, and the only one a task might still want.
   */
  const chooseDueDate = (chosen: { date: Date; label: string } | null) => {
    setDueDate(chosen);
    setDatePickerOpen(false);
    window.setTimeout(() => notesRef.current?.focus(), PICKER_HANDOFF_MS);
  };

  const canSubmit = title.trim().length > 0 && !create.isPending;

  const submit = () => {
    if (!canSubmit) return;
    create.mutate(
      {
        description: title.trim(),
        ...(notes.trim() ? { notes: notes.trim() } : {}),
        ...(deal ? { conversationId: deal.id } : {}),
        ...(groupId ? { taskGroupId: groupId } : {}),
        ...(dueDate ? { dueDate: dueDate.date.toISOString() } : {}),
      },
      {
        onSuccess: (result) => {
          writeDraft(null);
          onCreated();
          void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] });
          onOpenChange(false);
          // Land on the task just made: a brand-new task has no output to route to, so the
          // task itself is what to show.
          const id = (result as { task?: { id?: string } } | undefined)?.task?.id;
          if (id) openTaskView(id);
        },
        onError: () => toast.error('Could not add task'),
      },
    );
  };

  const group = groups.find((g) => g.id === groupId) ?? null;
  const GroupIcon = taskGroupIcon(group?.icon);

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent
        showCloseButton={false}
        overlayClassName={DIM_BACKDROP ? undefined : 'bg-transparent'}
        className={cn(
          'bg-popover top-[108px] left-1/2 block w-[750px] max-w-[calc(100vw-24px)] translate-x-[-50%] translate-y-0 sm:max-w-[calc(100vw-24px)]',
          'gap-0 rounded-[21px] border-[0.5px] p-0',
          'shadow-[0_9px_48px_rgb(0_0_0/0.08),0_6px_24px_rgb(0_0_0/0.10),0_1px_1px_rgb(0_0_0/0.04)]',
        )}
        onOpenAutoFocus={(e) => {
          e.preventDefault();
          titleRef.current?.focus();
        }}
        onKeyDown={(e) => {
          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
            e.preventDefault();
            submit();
          }
        }}
      >
        {/* Header — 12px all round, breadcrumb left, actions right. */}
        <div className="flex items-start p-3">
          <div className="flex min-w-0 flex-1 items-center">
            <Breadcrumb
              className="py-0"
              items={[
                {
                  label: group?.name ?? 'Tasks',
                  icon: (
                    <GroupIcon style={group?.color ? { color: group.color } : undefined} />
                  ),
                },
                { label: <DialogTitle className="truncate text-sm font-normal">New task</DialogTitle> },
              ]}
            />
          </div>
          <div className="flex items-center gap-1.5 pl-3">
            <button
              type="button"
              aria-label="Close"
              onClick={() => onOpenChange(false)}
              className="text-muted-foreground hover:bg-sunken hover:text-foreground flex size-[27px] shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors"
            >
              <X className="size-4" />
            </button>
          </div>
        </div>

        {/* Body — nested padding gives the text an 18px inset against the header's 12px. */}
        <div className="flex px-1.5">
          <div className="flex min-w-0 flex-1 flex-col gap-1.5 px-3">
            <textarea
              ref={titleRef}
              rows={1}
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              onKeyDown={(e) => {
                // Enter starts the chain: deal → date → description. The description comes
                // last because it is the one field a task might not need at all.
                if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
                  e.preventDefault();
                  setDealPickerOpen(true);
                }
              }}
              placeholder="Task title"
              className="placeholder:text-muted-foreground/70 text-foreground w-full resize-none bg-transparent pt-0.5 text-lg font-semibold outline-none"
            />
            <textarea
              ref={notesRef}
              rows={3}
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
              placeholder="Add description…"
              className="placeholder:text-muted-foreground/70 text-foreground min-h-20 w-full resize-none bg-transparent pb-3 text-sm outline-none"
            />
          </div>
        </div>

        {/* Footer — Linear puts an attachment button in the left slot; we have no attachments,
            so the properties live there instead and the row keeps its space-between shape. */}
        <div className="flex items-center justify-between gap-4.5 px-3 pt-1.5 pb-3">
          <div className="flex min-w-0 flex-wrap items-center gap-1.5">
            <DealChip
              value={deal}
              onChange={chooseDeal}
              open={dealPickerOpen}
              onOpenChange={setDealPickerOpen}
              enabled={open}
            />
            <DueDateChip
              value={dueDate}
              onChange={chooseDueDate}
              open={datePickerOpen}
              onOpenChange={setDatePickerOpen}
            />
            <GroupChip value={groupId} group={group} groups={groups} onChange={setGroupId} />
          </div>
          <button
            type="button"
            disabled={!canSubmit}
            onClick={submit}
            className="bg-primary text-primary-foreground flex h-7 shrink-0 cursor-pointer items-center rounded-full px-2.5 text-xs font-medium shadow-[0_3px_6px_-2px_rgb(0_0_0/0.02),0_1px_1px_rgb(0_0_0/0.04)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
          >
            {create.isPending ? 'Creating…' : 'Create task'}
          </button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

/** Deal — async conversation search, or Personal (no deal). Open state is parent-controlled. */
function DealChip({
  value,
  onChange,
  open,
  onOpenChange,
  enabled,
}: {
  value: ChosenConversation | null;
  onChange: (v: ChosenConversation | null) => void;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** The dialog is open — don't run the search behind a closed modal. */
  enabled: boolean;
}) {
  const trpc = useTRPC();
  const [query, setQuery] = useState('');
  const search = useQuery({
    // `dealsOnly` because the input says "Search a deal…". Without it this searches every
    // conversation the user owns — including the one Cedar minted for each unrecognised
    // correspondent — so typing "gmail" answers with a column of recruiters' addresses.
    ...trpc.crm.searchConversationsMinimal.queryOptions({ query, limit: 8, dealsOnly: true }),
    enabled: enabled && open,
  });
  const conversations =
    (
      search.data as
        | {
            conversations?: {
              id: string;
              name: string | null;
              companyName: string | null;
              nextStepDate: string | null;
            }[];
          }
        | undefined
    )?.conversations ?? [];

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <button type="button" className={cn(CHIP, value && CHIP_SET)}>
          {value ? (
            <ConversationCompanyAvatar
              conversationId={value.id}
              fallback={value.label}
              className="size-3.5 shrink-0 rounded-[3px]"
            />
          ) : (
            <User className="size-3.5 shrink-0" />
          )}
          <span className="max-w-40 truncate">{value?.label ?? 'Deal'}</span>
        </button>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={PICKER_SURFACE_CLASS}
        // Closing this hands focus back to the chip by default, which lands after the date
        // picker has opened and dismisses it — the chain would stop dead at the deal.
        onCloseAutoFocus={(e) => e.preventDefault()}
      >
        <Command shouldFilter={false} className="bg-transparent">
          <Cmdk.Input
            autoFocus
            value={query}
            onValueChange={setQuery}
            placeholder="Search a deal…"
            className="placeholder:text-muted-foreground/70 h-9 w-full bg-transparent px-2.5 text-sm outline-none"
          />
          <div className="bg-border/60 h-px" />
          <CommandList className="max-h-56 p-1">
            <CommandItem
              value="__personal__"
              onSelect={() => {
                onChange(null);
                onOpenChange(false);
              }}
              className={cn(PICKER_ITEM, 'text-muted-foreground')}
            >
              <User className="size-4 shrink-0" />
              Personal
            </CommandItem>
            {query.trim() && conversations.length === 0 && !search.isLoading && (
              <CommandEmpty className="py-3">No deals found.</CommandEmpty>
            )}
            {conversations.map((c) => {
              const label = c.companyName || c.name || 'Untitled';
              return (
                <CommandItem
                  key=[redacted]
                  value={c.id}
                  onSelect={() => {
                    onChange({ id: c.id, label, nextStepDate: c.nextStepDate ?? null });
                    onOpenChange(false);
                  }}
                  className={PICKER_ITEM}
                >
                  <ConversationCompanyAvatar
                    conversationId={c.id}
                    fallback={label}
                    className="size-4 shrink-0 rounded-[3px]"
                  />
                  <span className="min-w-0 truncate">{label}</span>
                </CommandItem>
              );
            })}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

/** Due date — natural language, same suggestion engine the old card used. */
function DueDateChip({
  value,
  onChange,
  open,
  onOpenChange,
}: {
  value: { date: Date; label: string } | null;
  onChange: (v: { date: Date; label: string } | null) => void;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  const [query, setQuery] = useState('');
  const fuse = useMemo(() => createDateFuseInstance(), []);
  const suggestions = useMemo(
    () => generateDateSuggestions(query, { fuseInstance: fuse }),
    [query, fuse],
  );

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <button type="button" className={cn(CHIP, value && CHIP_SET)}>
          <CalendarClock className="size-3.5 shrink-0" />
          <span className="max-w-40 truncate">{value?.label ?? 'Due date'}</span>
        </button>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={PICKER_SURFACE_CLASS}
        // Radix restores focus to the trigger chip on close, which would land after the
        // chain's own focus call and undo it. The chain decides where focus goes.
        onCloseAutoFocus={(e) => e.preventDefault()}
      >
        <Command shouldFilter={false} className="bg-transparent">
          <Cmdk.Input
            autoFocus
            value={query}
            onValueChange={setQuery}
            placeholder="Due date — try “tomorrow”…"
            className="placeholder:text-muted-foreground/70 h-9 w-full bg-transparent px-2.5 text-sm outline-none"
          />
          <div className="bg-border/60 h-px" />
          <CommandList className="max-h-56 p-1">
            <CommandItem
              value="__no_date__"
              onSelect={() => {
                onChange(null);
                onOpenChange(false);
              }}
              className={cn(PICKER_ITEM, 'text-muted-foreground')}
            >
              <CircleSlash className="size-4 shrink-0" />
              No due date
            </CommandItem>
            {suggestions.map((s) => (
              <CommandItem
                key=[redacted]
                value={s.id}
                onSelect={() => {
                  onChange({ date: s.date, label: s.label });
                  onOpenChange(false);
                }}
                className={PICKER_ITEM}
              >
                <CalendarClock className="text-muted-foreground size-4 shrink-0" />
                <span className="min-w-0 flex-1 truncate">{s.label}</span>
                {s.sublabel && (
                  <span className="text-muted-foreground shrink-0 text-xs">{s.sublabel}</span>
                )}
              </CommandItem>
            ))}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

/** Group — which column the task lands in. Carries the group's own icon, as the board does. */
function GroupChip({
  value,
  group,
  groups,
  onChange,
}: {
  value: string | null;
  group: { name: string; color: string | null; icon: string | null } | null;
  groups: { id: string | null; name: string; color: string | null; icon: string | null }[];
  onChange: (v: string | null) => void;
}) {
  const [open, setOpen] = useState(false);
  const MISC = '__misc__';
  const ChipIcon = taskGroupIcon(group?.icon);
  const options = groups.map((g) => {
    const Icon = taskGroupIcon(g.icon);
    return {
      value: g.id ?? MISC,
      label: g.name,
      icon: (
        <Icon
          className="size-4 shrink-0"
          style={{ color: g.color ?? undefined }}
        />
      ),
    };
  });

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button type="button" className={cn(CHIP, value && CHIP_SET)}>
          <ChipIcon
            className="size-3.5 shrink-0"
            style={group?.color ? { color: group.color } : undefined}
          />
          <span className="max-w-40 truncate">{group?.name ?? 'Group'}</span>
        </button>
      </PopoverTrigger>
      <PopoverContent align="start" className={PICKER_SURFACE_CLASS}>
        <OptionPicker
          options={options}
          selected={value ? [value] : [MISC]}
          placeholder="Move to group…"
          onPick={(v) => {
            onChange(v === MISC ? null : v);
            setOpen(false);
          }}
          onClose={() => setOpen(false)}
        />
      </PopoverContent>
    </Popover>
  );
}