TaskTicketProperties.tsx14.8 KBView on GitHub

Introduced 1 production defect in 180 days, median 0 days to fix.

/**
 * TaskTicketProperties — the ticket's properties column, built to read like Linear's.
 *
 * One of two columns centred together beside the task body, with no border between them: a rule
 * there cut the ticket in half and made these read as a separate panel.
 *
 * Linear's properties are not a form. They are a quiet list of icon + value rows — no field
 * labels stacked above each control, no bordered inputs, no visible chrome until you hover.
 * The value IS the control: click "Todo" and the picker opens over it. Only the trailing
 * metadata groups (Linear's "Labels", "Project") carry a small grey section label.
 *
 * The first version of this rail did the opposite — `Status` / `Due` / `Lane` captions above
 * boxed selects — which is why it read as a settings panel bolted to the side of a document.
 *
 * All of that geometry now lives in `components/ui/property-rows.tsx` and is SHARED: a graph
 * node, a board card and a document draw their properties from the same parts, because they
 * are answering the same question and were each answering it differently. This file keeps only
 * what is a fact about a TASK — which three rows exist, what they mutate, what a teammate may
 * not touch.
 *
 * Priority and labels, which a Linear issue has, are still deliberately absent: Cedar tasks have
 * no such fields and inventing them would mean a migration plus teaching every other task
 * surface about them.
 *
 * The conversation is NOT here — it is the badge under the title, where the task card and the
 * list row also put it. It is the task's context, not one of its settings.
 *
 * A teammate's task renders every row as plain text. The mutations are owner-scoped server
 * side, so offering the controls would only produce a failed write.
 */
'use client';

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  Bot,
  CalendarDays,
  CircleCheck,
  CircleDashed,
  FileOutput,
  Linkedin,
  Mail,
  Video,
} from 'lucide-react';
import { SlackLogo, WhatsAppLogo } from '@/modules/inbox/components/channel-icons';
import { DatePickerDialog } from '@/components/ui/date-picker-dialog';
import { formatRelativeDate, getScheduledTextColor } from '@/modules/crm/utils/time';
import { taskOutputKind } from '@/modules/userTasks/utils/task-output';
import { toast } from 'sonner';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { taskOutputLabel } from '@/modules/userTasks/utils/task-output';
import {
  PROPERTY_ROW,
  PROPERTY_ROW_INTERACTIVE,
  PropertyRail,
  PropertyRows,
  PropertySection,
  PropertyStaticRow,
} from '@/components/ui/property-rows';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';

/**
 * The subset of the fetched task this rail reads.
 *
 * Deliberately loose where the tRPC output is: drizzle infers the `taskGroup` relation as an
 * empty object type until its columns are named, and an `executions` row carries two dozen
 * fields of which one is wanted. Restating either shape exactly would mean re-declaring server
 * types here and re-breaking on every column added to them.
 */
export interface TaskTicketPropertiesTask {
  id: string;
  status: string;
  dueDate: string | Date | null;
  taskGroup: { id?: string | null; name?: string | null; color?: string | null; icon?: string | null } | null;
  taskOutput: { kind?: string | null } | null;
  taskCreatedBy: string | null;
  createdAt?: string | Date | null;
  conversationId: string | null;
  /**
   * The email or meeting whose processing produced this task — `agent_executions.event`, via the
   * run in `creationRunId`. Null for most tasks (a cron sweep, a task you typed yourself), which
   * is why the row renders only when there is one.
   */
  triggeringEvent?: {
    id: string;
    title: string | null;
    eventType: string | null;
    /** Present only for an email event — the one kind we can open precisely. */
    threadId: string | null;
  } | null;
  executions?: readonly { createdAt: string | Date }[];
}

/** The virtual Misc lane has no row of its own, so `null` is its value. */
const MISC_VALUE = '__misc__';

const ROW = PROPERTY_ROW;
const INTERACTIVE_ROW = PROPERTY_ROW_INTERACTIVE;

/** The glyph for the channel a triggering event arrived on. */
function TriggeringEventIcon({ eventType }: { eventType: string | null }) {
  const className = 'h-4 w-4 shrink-0';
  switch (eventType) {
    case 'meeting':
      return <Video className={cn(className, 'text-muted-foreground')} />;
    case 'slack_message':
      return <SlackLogo className={className} />;
    default:
      return <Mail className={cn(className, 'text-muted-foreground')} />;
  }
}

/**
 * The due date as the relative badge words, in the badge's colour, without the pill.
 *
 * Same `formatRelativeDate` and `getScheduledTextColor` RelativeDateBadge uses — so "in 3 days"
 * and its overdue-red read identically to the task card — but as text, because a rounded
 * background is a lot of furniture for one value in a properties column.
 */
function RelativeDueText({ date }: { date: string | Date }) {
  return <span className={cn('truncate', getScheduledTextColor(date))}>{formatRelativeDate(date)}</span>;
}

function formatDate(value: string | Date | null | undefined): string {
  if (!value) return 'No due date';
  const d = new Date(value);
  if (Number.isNaN(d.getTime())) return 'No due date';
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}

/**
 * The glyph for what a task produces — the channel's own brand mark where it has one, so the
 * Output row reads the same as the channel does everywhere else in the product.
 */
function OutputIcon({ task }: { task: { taskOutput: { kind?: string | null } | null } }) {
  switch (taskOutputKind(task)) {
    case 'slack':
      return <SlackLogo className="h-4 w-4" />;
    case 'whatsapp':
      return <WhatsAppLogo className="h-4 w-4" />;
    case 'linkedin':
      return <Linkedin className="h-4 w-4 text-[#0A66C2]" />;
    case 'email':
      return <Mail className="h-4 w-4 text-muted-foreground" />;
    case 'calendar':
      return <CalendarDays className="h-4 w-4 text-muted-foreground" />;
    default:
      return <FileOutput className="h-4 w-4 text-muted-foreground" />;
  }
}

export function TaskTicketProperties({
  task,
  editable,
  onOpenTriggeringEvent,
}: {
  task: TaskTicketPropertiesTask;
  /** False for a teammate's task — every mutation here is owner-scoped server side. */
  editable: boolean;
  /** Opens the event the task came from. Omitted when there is nothing to open. */
  onOpenTriggeringEvent?: () => void;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const { data: groupData } = useQuery({
    ...trpc.taskGroups.listGroups.queryOptions({}),
    enabled: editable,
  });
  // listGroups already appends the virtual Misc lane with `id: null`, so the picker needs no
  // synthetic entry of its own.
  const groups =
    (groupData as { groups?: { id: string | null; name: string }[] } | undefined)?.groups ?? [];

  // The due-date picker — the app's shared dialog, so a date is typed here exactly as it is
  // everywhere else ("tomorrow", "next week", "aug 7") rather than picked from a short list.
  const [dueOpen, setDueOpen] = useState(false);

  /** Every mutation invalidates the same three reads: this ticket, the task lists, the lanes. */
  const invalidate = () => {
    void queryClient.invalidateQueries({
      queryKey=[redacted] taskId: task.id }),
    });
    void queryClient.invalidateQueries({ queryKey=[redacted] });
    // The lane counts move whenever a task's group or status does.
    void queryClient.invalidateQueries({ queryKey: [['taskGroups']] });
  };

  const onError = () => toast.error('Could not update the task');

  const setStatus = useMutation({
    ...trpc.userTasks.updateTaskStatus.mutationOptions(),
    onSuccess: invalidate,
    onError,
  });
  const setDueDate = useMutation({
    ...trpc.userTasks.updateTaskDueDate.mutationOptions(),
    onSuccess: invalidate,
    onError,
  });
  const setGroup = useMutation({
    ...trpc.taskGroups.moveTaskToGroup.mutationOptions(),
    onSuccess: invalidate,
    onError,
  });
  const isDone = task.status === 'done';
  const statusLabel = isDone ? 'Done' : 'Todo';
  const StatusIcon = isDone ? CircleCheck : CircleDashed;
  // The lane keeps its own icon and colour — that pairing is how a lane is recognised at a
  // glance everywhere else in the product.
  const LaneIcon = taskGroupIcon(task.taskGroup?.icon ?? null);
  const laneName = task.taskGroup?.name ?? 'Misc';
  const laneColor = task.taskGroup?.color ?? undefined;
  const lastExecution = task.executions?.[0] ?? null;

  return (
    <PropertyRail className="w-64 shrink-0">
      <PropertyRows>
        {editable ? (
          // updateTaskStatus accepts todo | done only — the other statuses are reached by
          // deleting, not by picking.
          <Select
            value={isDone ? 'done' : 'todo'}
            onValueChange={(value) =>
              setStatus.mutate({ taskId: task.id, status: value as 'todo' | 'done' })
            }
          >
            <SelectTrigger className={INTERACTIVE_ROW}>
              <StatusIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
              <span className="min-w-0 truncate">{statusLabel}</span>
            </SelectTrigger>
            <SelectContent align="start">
              <SelectItem value="todo">
                <span className="flex items-center gap-2">
                  <CircleDashed className="h-4 w-4 text-muted-foreground" />
                  Todo
                </span>
              </SelectItem>
              <SelectItem value="done">
                <span className="flex items-center gap-2">
                  <CircleCheck className="h-4 w-4 text-muted-foreground" />
                  Done
                </span>
              </SelectItem>
            </SelectContent>
          </Select>
        ) : (
          <PropertyStaticRow icon={<StatusIcon className="h-4 w-4" />}>{statusLabel}</PropertyStaticRow>
        )}

        {editable ? (
          <button type="button" onClick={() => setDueOpen(true)} className={INTERACTIVE_ROW}>
            <CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" />
            {/* The badge alone — a relative date is the readable form, and printing the absolute
                one beside it says the same thing twice. */}
            {task.dueDate ? (
              <RelativeDueText date={task.dueDate} />
            ) : (
              <span className="text-muted-foreground">No due date</span>
            )}
          </button>
        ) : (
          <div className={ROW}>
            <CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" />
            {task.dueDate ? (
              <RelativeDueText date={task.dueDate} />
            ) : (
              <span className="text-muted-foreground">No due date</span>
            )}
          </div>
        )}

        {editable ? (
          <Select
            value={task.taskGroup?.id ?? MISC_VALUE}
            onValueChange={(value) =>
              setGroup.mutate({ taskId: task.id, groupId: value === MISC_VALUE ? null : value })
            }
          >
            <SelectTrigger className={INTERACTIVE_ROW}>
              <LaneIcon
                className="h-4 w-4 shrink-0"
                style={laneColor ? { color: laneColor } : undefined}
              />
              <span className="min-w-0 truncate">{laneName}</span>
            </SelectTrigger>
            <SelectContent align="start">
              {groups.map((group) => (
                <SelectItem key=[redacted] ?? MISC_VALUE} value={group.id ?? MISC_VALUE}>
                  {group.name}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        ) : (
          // A null lane IS a lane — the virtual Misc one — not missing data.
          <PropertyStaticRow
            icon={
              <LaneIcon className="h-4 w-4" style={laneColor ? { color: laneColor } : undefined} />
            }
          >
            {laneName}
          </PropertyStaticRow>
        )}

      </PropertyRows>

      <div className="mt-4 flex flex-col gap-3">
        <PropertySection label="Output">
          <PropertyStaticRow icon={<OutputIcon task={task} />}>{taskOutputLabel(task)}</PropertyStaticRow>
        </PropertySection>

        <PropertySection label="Created">
          <PropertyStaticRow icon={<Bot className="h-4 w-4" />}>
            {task.taskCreatedBy === 'agent' ? 'by Cedar agent' : 'by you'}
          </PropertyStaticRow>
          {task.createdAt && (
            <PropertyStaticRow icon={<CalendarDays className="h-4 w-4" />}>
              <span className="text-muted-foreground">{formatDate(task.createdAt)}</span>
            </PropertyStaticRow>
          )}
          {lastExecution && (
            <PropertyStaticRow icon={<span className="text-muted-foreground text-[10px]">↻</span>}>
              <span className="text-muted-foreground">
                Last run {formatDate(lastExecution.createdAt)}
              </span>
            </PropertyStaticRow>
          )}
        </PropertySection>

        {/* What set the task off — the meeting that was just summarised, the email or Slack
            thread that was just read. Its own property rather than a line under Created: those
            are two different facts, and buried under another caption this one reads as absent.
            Rendered only when there is one — most tasks have no triggering event, and a
            permanent "None" row would be noise on the majority of tickets. */}
        {task.triggeringEvent && (
          <PropertySection label="Triggering event">
            <button
              type="button"
              onClick={onOpenTriggeringEvent}
              disabled={!onOpenTriggeringEvent}
              title={task.triggeringEvent.title ?? undefined}
              className={cn(
                INTERACTIVE_ROW,
                'text-left disabled:cursor-default disabled:hover:bg-transparent',
              )}
            >
              <TriggeringEventIcon eventType={task.triggeringEvent.eventType} />
              <span className="min-w-0 truncate">
                {task.triggeringEvent.title || 'Open event'}
              </span>
            </button>
          </PropertySection>
        )}

      </div>

      <DatePickerDialog
        open={dueOpen}
        onOpenChange={setDueOpen}
        title="Due date"
        onSelect={(date) => {
          setDueOpen(false);
          setDueDate.mutate({ taskId: task.id, dueDate: date.toISOString() });
        }}
        lastUsedDate={task.dueDate ? new Date(task.dueDate) : null}
      />
    </PropertyRail>
  );
}