TaskListView.tsx19.4 KBView on GitHub
'use client';

/**
 * TaskListView — every open task as rows, grouped into Linear-style sections.
 *
 * Grouped by default (task group). Each section is a full-width header band — collapse chevron,
 * colour dot, name, count, and a hover "+" — followed by flat, dense, hairline-separated rows and a
 * bottom inline composer. The toolbar's Group-by (task group / due date), Ordering, Filters, Show
 * completed and Display properties all drive this via URL params (see useTaskListViewOptions).
 *
 * Like the board it reads `listUserTasks` (status 'todo', limit 500) as a hydration feed into the
 * slice and renders SOLELY from the slice (`useTodoTasks`), so optimistic complete/snooze/delete
 * take effect instantly. Completed tasks (opt-in) come from their own status:'done' query.
 */
import {
  makeTaskFilter,
  MISC_FILTER_KEY,
  NO_STATUS_KEY,
} from '@/modules/userTasks/utils/task-filters';
import { useOpenTaskInExecutionMode } from '@/modules/userTasks/hooks/use-open-task-in-execution-mode';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { useTaskListViewOptions } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { openConversationFromAgenda } from '@/modules/agentCanvas/utils/open-conversation';
import { useHydrateTasksSlice } from '@/modules/userTasks/hooks/use-hydrate-tasks-slice';
import { useTaskListHotkeys } from '@/modules/userTasks/hooks/use-task-list-hotkeys';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { humanizeStatus } from '@/modules/userTasks/utils/humanize-status';
import { listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import { useDayKey } from '@/modules/userTasks/hooks/use-day-boundary';
import { enterChatThread } from '@/modules/ux/layout/enterChatThread';
import { DatePickerDialog } from '@/components/ui/date-picker-dialog';
import { compareTasks } from '@/modules/userTasks/utils/task-order';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { useCedarStore, useTodoTasks } from '@/modules/store';
import { DEFAULT_STATUS_OPTIONS } from '@/modules/crm/types';
import { useTRPC } from '@/providers/query-provider';
import type { TaskCardTask } from './TaskKanbanCard';
import { useMemo, useRef, useState } from 'react';
import { NewTaskDialog } from './NewTaskDialog';
import { TaskListRow } from './TaskListRow';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

// The grouping keys are the filter model's keys — a section and its filter option must agree.
const MISC_KEY=[redacted];

/** Pipeline-order rank for a deal status (from the default CRM stages); unknown/custom sort after. */
const STATUS_ORDER = new Map(DEFAULT_STATUS_OPTIONS.map((o, i) => [o.value, i]));

/**
 * A labelled bucket for the due-date grouping — mail-style, most-recent first: Today, then the
 * recent past sliding to the distant past. There's no "Upcoming" lane — anything due today or later
 * folds into Today, so the sections are exclusively Today · Yesterday · Last 7 days · Older.
 */
function dueBucket(dueDate?: string | Date | null): { key=[redacted]; label: string; order: number } {
  if (!dueDate) return { key=[redacted], label: 'No date', order: 6 };
  const d = new Date(dueDate).getTime();
  const now = new Date();
  const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const day = 24 * 60 * 60 * 1000;
  if (d >= startToday) return { key=[redacted], label: 'Today', order: 1 };
  if (d >= startToday - day) return { key=[redacted], label: 'Yesterday', order: 2 };
  if (d >= startToday - 7 * day) return { key=[redacted], label: 'Last 7 days', order: 3 };
  return { key=[redacted], label: 'Older', order: 4 };
}

/** One labelled section of rows. `groupId` (grouping by task group) files new rows into it. */
interface TaskBlock {
  key=[redacted];
  label: string;
  groupId: string | null;
  color: string | null;
  tasks: TaskCardTask[];
}

/** Linear-style section header band. */
function GroupHeader({
  label,
  count,
  color,
  collapsed,
  onToggle,
  onAdd,
}: {
  label: string;
  count: number;
  color: string | null;
  collapsed: boolean;
  onToggle: () => void;
  onAdd: () => void;
}) {
  return (
    <div className="group/gh bg-muted/40 flex items-center gap-1.5 rounded-md px-2 py-1">
      <button
        type="button"
        onClick={onToggle}
        aria-label={collapsed ? `Expand ${label}` : `Collapse ${label}`}
        className="text-muted-foreground hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center"
      >
        {collapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
      </button>
      <span
        className={cn('size-2 shrink-0 rounded-full', !color && 'bg-muted-foreground/40')}
        style={color ? { backgroundColor: color } : undefined}
      />
      <span className="text-foreground truncate text-sm font-semibold">{label}</span>
      <span className="text-muted-foreground shrink-0 text-xs tabular-nums">{count}</span>
      <button
        type="button"
        onClick={onAdd}
        aria-label={`Add a task to ${label}`}
        className="text-muted-foreground hover:bg-sunken hover:text-foreground ml-auto flex size-5 shrink-0 cursor-pointer items-center justify-center rounded opacity-0 transition-opacity group-hover/gh:opacity-100"
      >
        <Plus className="size-3.5" />
      </button>
    </div>
  );
}

export function TaskListView() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const openTaskInExecutionMode = useOpenTaskInExecutionMode();
  const executeTaskNow = useExecuteTaskNow();
  const [selectedTaskId] = useQueryState('task', { history: 'push' });
  const { optimisticCompleteTask, optimisticSnoozeTask, optimisticDeleteTask } =
    useOptimisticTaskActions();
  const {
    groupBy,
    showCompleted,
    visibleProps,
    filterGroups,
    filterChannels,
    filterStatuses,
    filterCrm,
    filterTypes,
    hideFuture,
  } = useTaskListViewOptions();

  const [snoozeTaskId, setSnoozeTaskId] = useState<string | null>(null);
  // Session-only collapse state, keyed by section key.
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
  // The section whose inline "add task" composer is currently open (its key), or null.
  const [addingKey, setAddingKey] = useState<string | null>(null);
  // The row under the cursor — the target for the e/s/w/x hotkeys, falling back to the open task.
  const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
  // The section under the cursor — the target for the `c` (new task) hotkey.
  const [hoveredSectionKey, setHoveredSectionKey] = useState<string | null>(null);

  // Bulk selection (⌘/shift-click + x/shift+x), shared with the board.
  const toggleTaskSelection = useCedarStore((s) => s.toggleTaskSelection);
  const setTaskSelection = useCedarStore((s) => s.setTaskSelection);
  const setTaskSelectionAnchorId = useCedarStore((s) => s.setTaskSelectionAnchorId);
  const clearTaskSelection = useCedarStore((s) => s.clearTaskSelection);

  const { data: tasksData } = useQuery(
    trpc.userTasks.listUserTasks.queryOptions({
      status: 'todo',
      withConversation: true,
      limit: 500,
      sortDueDate: 'asc',
    }),
  );
  useHydrateTasksSlice(tasksData?.tasks);

  const { data: completedData } = useQuery({
    ...trpc.userTasks.listUserTasks.queryOptions({
      status: 'done',
      withConversation: true,
      limit: 100,
      sortDueDate: 'desc',
    }),
    enabled: showCompleted,
  });

  const { data: groupsData } = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));

  const reopenTask = useMutation(trpc.userTasks.updateTaskStatus.mutationOptions());

  const tasks = useTodoTasks() as unknown as TaskCardTask[];

  // The Board's comparator, not a second copy of it. Both surfaces render the stored `sortOrder`,
  // so re-seeding it from the toolbar (or dragging a card on the board) moves the row here too.
  const sortRows = useMemo(() => {
    const compare = compareTasks();
    return (rows: TaskCardTask[]) => [...rows].sort(compare);
  }, []);

  const dayKey=[redacted];

  // Future-due tasks are hidden by default here (a task due after today is not yet actionable);
  // the Board keeps them, in its Upcoming column.
  // `dayKey` re-runs this at local midnight: `hideFuture` compares against end-of-today, and a tab
  // left open overnight would otherwise keep yesterday's boundary and hide work that came due.
  const passesFilters = useMemo(
    () =>
      makeTaskFilter({
        filterGroups,
        filterChannels,
        filterStatuses,
        filterCrm,
        filterTypes,
        hideFuture,
      }),
    [filterGroups, filterChannels, filterStatuses, filterCrm, filterTypes, hideFuture, dayKey],
  );

  const ordered = useMemo(
    () => sortRows(tasks.filter(passesFilters)),
    [tasks, passesFilters, sortRows],
  );

  // Split into sections. Colours come from the group registry.
  const blocks = useMemo<TaskBlock[]>(() => {
    if (groupBy === 'none')
      return [{ key: 'all', label: 'Tasks', groupId: null, color: null, tasks: ordered }];

    if (groupBy === 'due') {
      const byKey = new Map<string, { label: string; order: number; tasks: TaskCardTask[] }>();
      for (const t of ordered) {
        const b = dueBucket(t.dueDate);
        const entry = byKey.get(b.key) ?? { label: b.label, order: b.order, tasks: [] };
        entry.tasks.push(t);
        byKey.set(b.key, entry);
      }
      return [...byKey.entries()]
        .sort((a, b) => a[1].order - b[1].order)
        .map(([key, v]) => ({ key, label: v.label, groupId: null, color: null, tasks: v.tasks }));
    }

    if (groupBy === 'status') {
      // Group by the attached deal's pipeline stage; ungrouped/dealless tasks go to "No stage".
      const byKey = new Map<string, { label: string; order: number; tasks: TaskCardTask[] }>();
      for (const t of ordered) {
        const status = t.conversation?.status ?? null;
        const key=[redacted] ?? NO_STATUS_KEY;
        const order = status ? (STATUS_ORDER.get(status) ?? 900) : 1000;
        const label = status ? humanizeStatus(status) : 'No stage';
        const entry = byKey.get(key) ?? { label, order, tasks: [] };
        entry.tasks.push(t);
        byKey.set(key, entry);
      }
      return [...byKey.entries()]
        .sort((a, b) => a[1].order - b[1].order || a[1].label.localeCompare(b[1].label))
        .map(([key, v]) => ({ key, label: v.label, groupId: null, color: null, tasks: v.tasks }));
    }

    // groupBy === 'group': task-group order, Misc last.
    const groups = groupsData?.groups ?? [];
    const byKey = new Map<string, TaskCardTask[]>();
    for (const t of ordered) {
      const key = (t as { taskGroupId?: string | null }).taskGroupId ?? MISC_KEY;
      (byKey.get(key) ?? byKey.set(key, []).get(key)!).push(t);
    }
    const out: TaskBlock[] = [];
    for (const g of groups) {
      if (g.isMisc || !g.id) continue; // Misc synthesized below
      const list = byKey.get(g.id);
      if (list?.length)
        out.push({ key=[redacted], label: g.name, groupId: g.id, color: g.color ?? null, tasks: list });
    }
    const misc = byKey.get(MISC_KEY);
    if (misc?.length)
      out.push({ key=[redacted], label: 'Misc', groupId: null, color: null, tasks: misc });
    return out;
  }, [groupBy, ordered, groupsData]);

  const completed = useMemo(
    () => ((completedData?.tasks ?? []) as unknown as TaskCardTask[]).filter(passesFilters),
    [completedData, passesFilters],
  );

  const refetchTasks = () =>
    void queryClient.invalidateQueries({ queryKey=[redacted] });

  // Execute runs the task on its own chat thread — which the sidepanel switches to, so the run is
  // watchable — and attaches the produced draft back to the task: the row shows an Executing state,
  // then flips to "Open draft". The list itself stays put; the task is not opened.
  const handleExecute = (task: TaskCardTask) =>
    void executeTaskNow({
      taskId: task.id,
      conversationId: task.conversationId,
      description: task.description || 'Untitled task',
    });

  // The row's "Chat" button repoints the sidepanel at the task's run without leaving the list, so
  // it enters the chat in place (no navigate) — see enterChatThread.
  const openChat = (chatThreadId: string) => enterChatThread(chatThreadId);

  const toggleCollapse = (key=[redacted] =>
    setCollapsed((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });

  // Header "+" — open (and expand) this section's inline composer.
  const startAdding = (key=[redacted] => {
    setCollapsed((prev) => {
      if (!prev.has(key)) return prev;
      const next = new Set(prev);
      next.delete(key);
      return next;
    });
    setAddingKey(key);
  };

  // Flat visible order (sections top-to-bottom, rows in each) for shift-range selection.
  const orderedIds = useMemo(() => blocks.flatMap((b) => b.tasks.map((t) => t.id)), [blocks]);
  const orderedIdsRef = useRef<string[]>([]);
  orderedIdsRef.current = orderedIds;

  // A key acts on the whole selection when there is one, else the single target (hovered/open).
  const actOnTargets = (id: string, fn: (taskId: string) => void) => {
    const sel = useCedarStore.getState().taskSelection;
    const ids = sel.length ? sel : [id];
    ids.forEach(fn);
    if (sel.length) clearTaskSelection();
  };

  const toggleSelect = (id: string) => {
    toggleTaskSelection(id);
    setTaskSelectionAnchorId(id);
  };

  const rangeSelect = (id: string) => {
    const ids = orderedIdsRef.current;
    const { taskSelectionAnchorId, taskSelection } = useCedarStore.getState();
    const anchor = taskSelectionAnchorId ?? id;
    const ai = ids.indexOf(anchor);
    const ti = ids.indexOf(id);
    if (ai === -1 || ti === -1) {
      toggleSelect(id);
      return;
    }
    const [start, end] = ai <= ti ? [ai, ti] : [ti, ai];
    setTaskSelection([...taskSelection, ...ids.slice(start, end + 1)]);
  };

  const openRow = (t: TaskCardTask) => {
    clearTaskSelection();
    setTaskSelectionAnchorId(t.id);
    openTaskInExecutionMode(t);
  };

  useTaskListHotkeys({
    getTargetId: () => hoveredTaskId ?? selectedTaskId,
    onCreate: () => {
      // Open the composer in the hovered section, falling back to the first one.
      const key=[redacted] ?? blocks[0]?.key;
      if (key) startAdding(key);
    },
    onComplete: (id) => actOnTargets(id, (t) => void optimisticCompleteTask(t, 'checked-off')),
    onSnooze: (id) => setSnoozeTaskId(id),
    onDelete: (id) => actOnTargets(id, (t) => void optimisticDeleteTask(t)),
    onToggleSelect: toggleSelect,
    onRangeSelect: rangeSelect,
  });

  const renderRow = (task: TaskCardTask) => (
    <TaskListRow
      key=[redacted]
      task={task}
      isActive={task.id === selectedTaskId}
      visibleProps={visibleProps}
      onOpen={openRow}
      onOpenConversation={(id) => openConversationFromAgenda(id)}
      onOpenChat={openChat}
      onExecute={handleExecute}
      onToggleComplete={(id) => void optimisticCompleteTask(id, 'checked-off')}
      onSnooze={(id) => setSnoozeTaskId(id)}
      onDelete={(id) => void optimisticDeleteTask(id)}
      onToggleSelect={toggleSelect}
      onRangeSelect={rangeSelect}
      onHover={setHoveredTaskId}
    />
  );

  const empty = blocks.every((b) => b.tasks.length === 0);
  const completedCollapsed = collapsed.has('__completed__');

  return (
    <>
      <div className="flex flex-col gap-1 pl-6">
        {empty ? (
          <div className="text-muted-foreground px-2 py-10 text-center text-sm">
            No active tasks
            <div className="mt-3">
              <button
                type="button"
                onClick={() => startAdding(blocks[0]?.key ?? 'all')}
                className="text-muted-foreground hover:bg-muted/50 hover:text-foreground mx-auto flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors"
              >
                <Plus className="size-3.5 shrink-0" />
                Add task
              </button>
            </div>
          </div>
        ) : (
          blocks.map((block) => {
            const isCollapsed = collapsed.has(block.key);
            return (
              <div
                key=[redacted]
                className="flex flex-col"
                onMouseEnter={() => setHoveredSectionKey(block.key)}
                onMouseLeave={() =>
                  setHoveredSectionKey((prev) => (prev === block.key ? null : prev))
                }
              >
                <GroupHeader
                  label={block.label}
                  count={block.tasks.length}
                  color={block.color}
                  collapsed={isCollapsed}
                  onToggle={() => toggleCollapse(block.key)}
                  onAdd={() => startAdding(block.key)}
                />
                {!isCollapsed && (
                  <div className="divide-border/40 divide-y">{block.tasks.map(renderRow)}</div>
                )}
              </div>
            );
          })
        )}

        {/* Completed — opt-in section. Un-checking reopens the task. */}
        {showCompleted && completed.length > 0 && (
          <div className="mt-1 flex flex-col">
            <GroupHeader
              label="Completed"
              count={completed.length}
              color={null}
              collapsed={completedCollapsed}
              onToggle={() => toggleCollapse('__completed__')}
              onAdd={() => {}}
            />
            {!completedCollapsed && (
              <div className="divide-border/40 divide-y">
                {completed.map((task) => (
                  <TaskListRow
                    key=[redacted]
                    task={task}
                    visibleProps={visibleProps.filter((p) => p !== 'action')}
                    onOpen={(t) => openTaskInExecutionMode(t)}
                    onOpenConversation={(id) => openConversationFromAgenda(id)}
                    onToggleComplete={(id) =>
                      reopenTask.mutate(
                        { taskId: id, status: 'todo' },
                        {
                          onSuccess: refetchTasks,
                          onError: () => toast.error('Could not reopen task'),
                        },
                      )
                    }
                  />
                ))}
              </div>
            )}
          </div>
        )}
      </div>

      {/* The natural-language snooze picker — same modal the board and email snooze use. */}
      <DatePickerDialog
        open={snoozeTaskId !== null}
        onOpenChange={(open) => !open && setSnoozeTaskId(null)}
        onSelect={(date) => {
          if (snoozeTaskId) void optimisticSnoozeTask(snoozeTaskId, date);
          setSnoozeTaskId(null);
        }}
        title="Snooze until"
        placeholder="Try: tomorrow, next week, or aug 7"
      />

      {/* Creating a task is a modal, not a row spliced into the section — see NewTaskDialog.
          The section that opened it only decides which group the task lands in. */}
      <NewTaskDialog
        open={addingKey !== null}
        onOpenChange={(open) => {
          if (!open) setAddingKey(null);
        }}
        taskGroupId={blocks.find((b) => b.key === addingKey)?.groupId ?? null}
        onCreated={refetchTasks}
      />
    </>
  );
}