userTasksSlice.ts38.9 KBView on GitHub
import { DEFAULT_STATUS_OPTIONS, DEFAULT_PRIORITY_OPTIONS } from '@/modules/crm/types';
import { applyPendingTaskResolutions } from '@/modules/userTasks/lib/pending-task-resolutions';
import type { ConversationTaskActionData, ConversationUserTask } from '@/modules/crm/types';
import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import { getEnumDisplayText } from '../../crm/utils';
import type { StateCreator } from 'zustand';

// Re-export types that will be used in hooks

// Date keys for the task navigation map
export type TaskDateKey=[redacted] | '-3' | '-2' | '-1' | 'today' | '+1' | '+2' | '+3' | 'future';

// Map of date key to task/execution IDs
export type TaskIdsByDateKey=[redacted], string[]>;
export type ExecutionIdsByDateKey=[redacted], string[]>;

// ============================================
// TASK CHANNEL & TYPE ENUMS (Task-Centric Refactor)
// ============================================

// LEGACY declaration axis. Still written for the expand/contract window, but never branched on:
// every read goes through `taskOutput.kind` (see modules/userTasks/utils/task-output.ts), which is
// authoritative because it records what the task produced rather than what it intended to produce.
export const TASK_CHANNELS = ['email', 'slack', 'linkedin', 'whatsapp'] as const;
export type TaskChannel = (typeof TASK_CHANNELS)[number];

// Mirrors TASK_TYPES in apps/server/src/db/aop-schema.ts — keep in sync.
export const TASK_TYPES = [
  'response',
  'follow-up',
  'reminder',
  'post-meeting',
  'pre-meeting',
  'reactivation',
  'manual',
  'calendar',
  'crm-opportunity',
  'field-approval',
] as const;
export type TaskType = (typeof TASK_TYPES)[number];

// ============================================
// TASK ACTION DATA TYPES (Task-Centric Refactor)
// Single object populated when action is taken on task
// ============================================

// Base task action data type
export type TaskActionDataBase = {
  channel: 'email' | 'slack' | 'calendar' | 'recommendation' | 'linkedin' | 'whatsapp';
};

// Email action data
export type EmailTaskActionData = TaskActionDataBase & {
  channel: 'email';
  /** Optional — a not-yet-sent outbound draft has no thread. Mirrors apps/server db/aop-schema.ts. */
  threadId?: string;
  draftId?: string;
  emailHeaderMessageId?: string;
};

// Slack action data
export type SlackTaskActionData = TaskActionDataBase & {
  channel: 'slack';
  /**
   * OPTIONAL, mirroring `aop-schema.ts`. The draft tools no longer write whatever the model put
   * in this slot — 45 of 757 rows held a word ("dm", "general"), a prompt placeholder, or a
   * channel NAME, none of which resolve — so a task can now carry a Slack output with no channel
   * to address. Every reader has to handle its absence; `openTask` falls back to the ticket.
   */
  channelId?: string;
  channelName?: string;
  workspaceId?: string;
  threadTs?: string;
  message?: string;
  draftId?: string;
};

// Calendar action data — set when the agent schedules an event.
// See FULLSTACK_AGENDA.md §11i.
export type CalendarTaskActionData = TaskActionDataBase & {
  channel: 'calendar';
  eventId: string;
  calendarId: string;
  htmlLink?: string;
  startTime?: string;
};

// Recommendation action data — agent-proposed action (status='recommended').
// Mirrors apps/server/src/db/aop-schema.ts.
export type RecommendationActionData = TaskActionDataBase & {
  channel: 'recommendation';
  sourceFieldId?: string;
};

// LinkedIn / WhatsApp action data — opens the chat in the omni-channel inbox;
// draft lives on the chat row. See apps/mail/docs/omni-channel-inbox.md Phase 13.
export type LinkedinTaskActionData = TaskActionDataBase & {
  channel: 'linkedin';
  chatId: string;
  unipileAccountId: string;
};

export type WhatsappTaskActionData = TaskActionDataBase & {
  channel: 'whatsapp';
  chatId: string;
  unipileAccountId: string;
  phoneE164?: string;
};

// Union type for all task action data
export type TaskActionData =
  | EmailTaskActionData
  | SlackTaskActionData
  | CalendarTaskActionData
  | RecommendationActionData
  | LinkedinTaskActionData
  | WhatsappTaskActionData;

// ============================================
// FOLLOW-UP TASK DEFINITION (Task-Centric Refactor)
// Blueprint for agent when task is completed (NOT auto-triggered)
// ============================================

export type FollowupTaskDefinition = {
  description: string;
  notes?: string;
  relativeDaysAfter: number; // Days after task completion to schedule follow-up
};

// Kept for backwards compatibility with listTaskLabels endpoint
export interface TaskLabel {
  id: string;
  name: string;
  displayName: string;
  description: string | null;
  color: string | null;
}

// ============================================
// HYDRATED USER TASK (Task-Centric Refactor)
// ============================================

/**
 * The conversation header attached by `userTasks.listUserTasks({ withConversation: true })`.
 * Absent when the caller didn't opt in, null when the task has no conversation. Every field
 * is optional because optimistic tasks attach whatever slice of the conversation they hold.
 */
export interface HydratedTaskConversation {
  name?: string | null;
  companyName?: string | null;
  logoUrl?: string | null;
  lastContactedAt?: Date | string | null;
  nextSteps?: string | null;
  nextStepDate?: Date | string | null;
  status?: string | null;
  priority?: string | null;
  /** Whether the deal resolved to a record in the user's external CRM (HubSpot/Salesforce/…). */
  crmSynced?: boolean;
  /** The playbook (AOP) driving this deal, for the board's Type filter — null if unassigned. */
  aopId?: string | null;
  aopName?: string | null;
}

export interface HydratedUserTask {
  id: string;
  userId: string;
  conversationId: string;

  // Which task group this task is filed under. null = the virtual Misc lane (no row
  // exists for Misc). Returned by listUserTasks, which selects the whole user_tasks
  // row — this type simply never declared it.
  taskGroupId: string | null;

  // ============================================
  // NEW FIELDS (Task-Centric Refactor)
  // ============================================

  // LEGACY intent axis, superseded by `taskOutput` below. Written, never read — branch on
  // `taskOutput.kind` instead.
  taskChannel: TaskChannel;

  // Task type: 'response' | 'follow-up' | 'post-meeting' | 'pre-meeting' | 'reactivation' | 'manual'
  taskType: TaskType | null;

  // Who created this task: 'agent' | 'user'
  taskCreatedBy: 'agent' | 'user' | null;

  // Single task action data object (populated when action taken, e.g., draft email).
  // Typed off the wire row so the channel variants cannot drift from the server's.
  taskActionData: ConversationTaskActionData | null;

  // What finishing this task produces — the output axis (TASK_AXES_DESIGN.md). `kind` is
  // always present and declares intent; the rest of the payload fills in once the artifact
  // exists. Supersedes taskChannel + taskActionData, both of which are still written for
  // the expand/contract window. Typed off the wire row so the variants cannot drift.
  taskOutput: ConversationUserTask['taskOutput'];

  // Whether agent should execute this task at dueDate
  agentExecutionEnabled: boolean;

  // Reference to execution that EXECUTED this task (if set, agent has executed)
  executionRunId: string | null;

  // Reference to execution that CREATED this task
  creationRunId: string | null;

  // Extra context for agent execution
  notes: string | null;

  // Manual-invocation chat thread — set by `agentExecutions.invokeTaskInChat`
  // when the user invokes this task from the agenda (FULLSTACK_AGENDA.md §11i).
  // Null until the user invokes; cleared by `userTasks.clearTaskOutput`.
  chatThreadId: string | null;

  // The email thread this task is ABOUT, as opposed to the two thread-shaped fields either
  // side of it: `taskActionData.threadId` is the thread of the draft the task PRODUCED, and
  // `chatThreadId` directly above is the task's own agent run. A reminder produces nothing
  // and is still plainly about a thread, which is the case this exists for. Nullable, and
  // absent on the task event payloads, so every writer coerces undefined → null.
  sourceThreadId: string | null;

  // Provenance for a task post-meeting triage derived from a note marker: the
  // notes document, and the marker's id inside it. `(sourceDocumentId,
  // sourceMarkerId)` is the pair triage keys on so a re-run adds only what is
  // new. Null on every user-created task — it came from a person, not a line in
  // a document. Absent on the task event payloads, so writers preserve rather
  // than overwrite (see clientExecutionResponseProcessors).
  sourceDocumentId: string | null;
  sourceMarkerId: string | null;

  // ============================================
  // CORE FIELDS (unchanged)
  // ============================================

  description: string | null;
  status: 'todo' | 'done' | 'deleted' | 'agent_deleted';

  // Obligation tags — who owes whom (CURATED_AGENDA_DESIGN.md Phase 9b). Written by the agent
  // at task-create time and never swept, so most rows carry the empty default for now.
  tags: string[];

  isRead: boolean;
  createdAt: Date;
  updatedAt: Date;
  completedAt: Date | null;
  dueDate: Date;

  // Where this card sits on the board — smaller is nearer the top. Only consulted when the
  // board's `orderBy` is 'manual'; every other mode sorts by its own field. Written by the
  // database (see user_tasks_sort_order.sql), so it arrives already correct on a new task.
  sortOrder: number;

  // Whether the USER dragged this card here. A pinned card holds its slot, and is skipped as a
  // reference point when a newly created task is placed by due date — its sortOrder no longer
  // says anything about its date, which is precisely what pinning means.
  sortOrderPinned: boolean;

  // ============================================
  // JOINED FIELDS (opt-in via listUserTasks withConversation)
  // ============================================

  conversation?: HydratedTaskConversation | null;
}

export interface ScheduledExecution {
  runId: string;
  userId: string;
  conversationId: string | null;
  scheduledFor: Date | string | null;
  status: 'pending' | 'completed' | 'canceled' | 'final' | 'failed';
  prompt?: string | null;
  summary?: string | null;
  aopName?: string;
  threadId?: string;
  emailSubject?: string;
  emailMessageId?: string;
}

// Sorting configuration types
export type SortableAttribute =
  // Conversation attributes
  | 'conversationStatus'
  | 'conversationPriority'
  | 'conversationType'
  | 'daysSinceLastContact'
  // UserTask attributes
  | 'taskType'
  | 'metadataType'
  // Thread attributes
  | 'threadLabels'
  | 'threadDate';

// Ordering configuration for attributes that have enum values
export interface AttributeOrderingValue {
  // Enum options carry `value: string | null` (null = "unset"), so the ordering mirrors that.
  value: string | null;
  label: string;
  title?: string; // Proper title for use in group headers (first item)
}

export interface AttributeOrderingConfig {
  attribute: SortableAttribute;
  values: AttributeOrderingValue[];
}

export interface SortingConfiguration {
  sort: SortableAttribute[];
  // Map of attribute -> ordered enum values (for sortable enums)
  ordering: Record<SortableAttribute, AttributeOrderingValue[]>;
  // Map of attribute -> visibility on thread items (true = visible, false = hidden)
  badgeVisibility?: Partial<Record<SortableAttribute, boolean>>;
}

// Default empty date key map
const EMPTY_DATE_KEY_MAP: TaskIdsByDateKey = {
  previous: [],
  '-3': [],
  '-2': [],
  '-1': [],
  today: [],
  '+1': [],
  '+2': [],
  '+3': [],
  future: [],
};

/**
 * User Tasks Slice State
 */
export interface UserTasksState {
  // Map of taskId -> task data
  tasks: Record<string, HydratedUserTask>;

  // Map of runId -> scheduled execution data
  scheduledExecutions: Record<string, ScheduledExecution>;

  // Map of date key -> task IDs (pre-computed for fast date navigation)
  taskIdsByDateKey=[redacted];

  // Map of date key -> execution IDs (pre-computed for fast date navigation)
  executionIdsByDateKey=[redacted];

  // Available task labels
  taskLabels: TaskLabel[];

  // Snooze dialog state
  snoozeDialogOpen: boolean;
  snoozeDialogTaskId: string | null;

  // Bulk selection on the task board (x / shift+x). Mirrors conversationsSlice's persisted-anchor
  // pattern: the anchor survives re-renders so a shift+x range-select resolves from the right edge.
  taskSelection: string[];
  taskSelectionAnchorId: string | null;

  // Tasks with an "execute now" run in flight. Drives the card's Executing indicator for the instant
  // window before the run's chat thread id lands — once it does, the thread's own isThreadProcessing
  // carries the indicator (see use-execute-task-now.ts).
  executingTaskIds: string[];

  // Sorting configuration
  sortingConfiguration: SortingConfiguration;

  // Last time data was fetched (for cache management)
  lastTasksFetchedAt: number;
  lastExecutionsFetchedAt: number;

  // Tasks view date navigation state
  tasksViewSelectedDate: Date;
}

/**
 * User Tasks Slice Actions
 */
export interface UserTasksSlice extends UserTasksState {
  // Task Management
  setTasks: (tasks: Record<string, HydratedUserTask>) => void;
  // Authoritative hydration of the open ("todo") working set. Upserts every incoming task AND
  // removes any slice task with status 'todo' that is absent from `incoming` — so a task completed
  // or deleted on the server (or in another tab) leaves the slice on the next fetch. This is what
  // lets the board/accordion render solely from the slice. `incoming` MUST be the full todo set for
  // the user (both surfaces query with the same scope); a partial list would drop the remainder.
  hydrateTodoTasks: (incoming: HydratedUserTask[]) => void;
  // Masked upsert of a PARTIAL server list — the agenda's day-ranged query, and anything else
  // that mirrors server tasks it does not own the full set of. Upserts like `setTasks` and masks
  // like `hydrateTodoTasks`, but reconciles no removals, because absence from a partial list
  // means "not in this range", not "gone". Use this, not `setTasks`, for anything arriving from
  // the server: `setTasks` is the raw write, correct only for values the client just computed.
  upsertServerTasks: (incoming: HydratedUserTask[]) => void;
  getTask: (taskId: string) => HydratedUserTask | undefined;
  getTasks: () => HydratedUserTask[];
  getTasksByStatus: (status: 'todo' | 'done') => HydratedUserTask[];
  removeTask: (taskId: string) => void;
  removeEmailTasksForConversation: (conversationId: string) => void;
  restoreTask: (taskId: string, task: HydratedUserTask) => void;
  updateTask: (taskId: string, updates: Partial<HydratedUserTask>) => void;
  markTaskAsRead: (taskId: string) => void;
  clearTasks: () => void;

  // Snooze Dialog Management
  openSnoozeDialog: (taskId: string) => void;
  closeSnoozeDialog: () => void;

  // Task board bulk selection (x / shift+x)
  setTaskSelection: (taskIds: string[]) => void;
  toggleTaskSelection: (taskId: string) => void;
  clearTaskSelection: () => void;
  setTaskSelectionAnchorId: (taskId: string | null) => void;
  isTaskSelected: (taskId: string) => boolean;

  // Background execute-now busy state (see executingTaskIds).
  setTaskExecuting: (taskId: string, executing: boolean) => void;
  isTaskExecuting: (taskId: string) => boolean;

  // Scheduled Executions Management
  setScheduledExecutions: (executions: Record<string, ScheduledExecution>) => void;
  getScheduledExecution: (runId: string) => ScheduledExecution | undefined;
  getScheduledExecutions: () => ScheduledExecution[];
  getScheduledExecutionsByStatus: (
    status: 'pending' | 'completed' | 'canceled' | 'final' | 'failed',
  ) => ScheduledExecution[];
  removeScheduledExecution: (runId: string) => void;
  updateScheduledExecution: (runId: string, updates: Partial<ScheduledExecution>) => void;
  restoreScheduledExecution: (runId: string, execution: ScheduledExecution) => void;
  clearScheduledExecutions: () => void;

  // Task Labels Management
  setTaskLabels: (labels: TaskLabel[]) => void;
  getTaskLabels: () => TaskLabel[];

  // Date-based task queries
  getTasksForDate: (date: Date) => HydratedUserTask[];
  getTasksBeforeDate: (date: Date) => HydratedUserTask[];
  getTasksAfterDate: (date: Date) => HydratedUserTask[];
  getScheduledExecutionsAfterDate: (date: Date) => ScheduledExecution[];

  // Date key map management (for fast date navigation)
  setTaskIdsByDateKey=[redacted] TaskIdsByDateKey) => void;
  setTaskIdsForDateKey=[redacted] TaskDateKey, taskIds: string[]) => void;
  setExecutionIdsByDateKey=[redacted] ExecutionIdsByDateKey) => void;
  getTaskIdsForDateKey=[redacted] TaskDateKey) => string[];
  getExecutionIdsForDateKey=[redacted] TaskDateKey) => string[];
  getTasksForDateKey=[redacted] TaskDateKey) => HydratedUserTask[];
  getExecutionsForDateKey=[redacted] TaskDateKey) => ScheduledExecution[];

  // Sorting Configuration Management
  setSortingConfiguration: (config: SortingConfiguration) => void;
  getSortingConfiguration: () => SortingConfiguration;
  updateAttributeOrdering: (attribute: SortableAttribute, values: AttributeOrderingValue[]) => void;
  toggleBadgeVisibility: (attribute: SortableAttribute) => void;

  // Tasks View Date Navigation
  setTasksViewSelectedDate: (date: Date) => void;
  navigateTasksViewDateLeft: () => void;
  navigateTasksViewDateRight: () => void;

  // Utility
  clearUserTasksSlice: () => void;
}

// Default ordering configuration for each sortable attribute
export const DEFAULT_ORDERING: Record<SortableAttribute, AttributeOrderingValue[]> = {
  conversationStatus: [
    // Derived from DEFAULT_STATUS_OPTIONS (single source of truth)
    ...DEFAULT_STATUS_OPTIONS.map((opt) => ({
      value: opt.value,
      label: getEnumDisplayText(opt.value, DEFAULT_STATUS_OPTIONS),
      title: `${getEnumDisplayText(opt.value, DEFAULT_STATUS_OPTIONS).charAt(0).toUpperCase() + getEnumDisplayText(opt.value, DEFAULT_STATUS_OPTIONS).slice(1)} Conversations`,
    })),
    { value: 'None', label: 'None', title: 'No Status' },
  ],
  conversationPriority: [
    // Derived from DEFAULT_PRIORITY_OPTIONS (single source of truth)
    ...DEFAULT_PRIORITY_OPTIONS.map((opt) => ({
      value: opt.value,
      label: getEnumDisplayText(opt.value, DEFAULT_PRIORITY_OPTIONS),
      title: `${getEnumDisplayText(opt.value, DEFAULT_PRIORITY_OPTIONS).charAt(0).toUpperCase() + getEnumDisplayText(opt.value, DEFAULT_PRIORITY_OPTIONS).slice(1)} Priority`,
    })),
    { value: 'None', label: 'None', title: 'No Priority' },
  ],
  conversationType: [
    { value: 'Deal', label: 'Deal', title: 'Deal Conversations' },
    { value: 'Inbound Spam', label: 'Inbound Spam', title: 'Inbound Spam' },
    { value: 'Recruiting', label: 'Recruiting', title: 'Recruiting Conversations' },
    { value: 'Partnership', label: 'Partnership', title: 'Partnership Conversations' },
    { value: 'General', label: 'General', title: 'General Conversations' },
    { value: 'None', label: 'None', title: 'No Type' },
  ],
  daysSinceLastContact: [
    { value: 'asc', label: 'Oldest First', title: 'Oldest Contacts' },
    { value: 'desc', label: 'Newest First', title: 'Newest Contacts' },
  ],
  taskType: [
    { value: 'response', label: 'Response', title: 'Responses' },
    { value: 'follow-up', label: 'Follow-up', title: 'Follow-ups' },
    { value: 'post-meeting', label: 'Post-meeting', title: 'Post-meeting' },
    { value: 'pre-meeting', label: 'Pre-meeting', title: 'Pre-meeting' },
    { value: 'reactivation', label: 'Reactivation', title: 'Reactivation' },
    { value: 'manual', label: 'Manual', title: 'Manual Tasks' },
    { value: 'tier-a', label: 'Tier A', title: 'Tier A Tasks' },
    { value: 'tier-b', label: 'Tier B', title: 'Tier B Tasks' },
    { value: 'tier-c', label: 'Tier C', title: 'Tier C Tasks' },
    { value: 'None', label: 'None', title: 'No Task Type' },
  ],
  metadataType: [
    { value: 'email', label: 'Email', title: 'Email Tasks' },
    { value: 'slack', label: 'Slack', title: 'Slack Tasks' },
    { value: 'None', label: 'None', title: 'No Metadata Type' },
  ],
  threadLabels: [
    { value: 'to respond', label: 'To Respond', title: 'To Respond' },
    { value: 'FYI', label: 'FYI', title: 'FYI' },
    { value: 'comment', label: 'Comment', title: 'Comments' },
    { value: 'notification', label: 'Notification', title: 'Notifications' },
    { value: 'promotion', label: 'Promotion', title: 'Promotions' },
    { value: 'meeting', label: 'Meeting', title: 'Meetings' },
    { value: 'billing', label: 'Billing', title: 'Billing' },
    { value: 'None', label: 'None', title: 'No Label' },
  ],
  threadDate: [
    { value: 'desc', label: 'Newest First', title: 'Recent Threads' },
    { value: 'asc', label: 'Oldest First', title: 'Older Threads' },
  ],
};

// Helper to get today's date at midnight
function getTodayMidnight(): Date {
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  return today;
}

const initialUserTasksState: UserTasksState = {
  tasks: {},
  scheduledExecutions: {},
  taskIdsByDateKey: { ...EMPTY_DATE_KEY_MAP },
  executionIdsByDateKey: { ...EMPTY_DATE_KEY_MAP },
  taskLabels: [],
  snoozeDialogOpen: false,
  snoozeDialogTaskId: null,
  taskSelection: [],
  taskSelectionAnchorId: null,
  executingTaskIds: [],
  sortingConfiguration: {
    sort: ['conversationPriority', 'threadDate'],
    ordering: DEFAULT_ORDERING,
    badgeVisibility: {
      conversationPriority: false, // Hide priority badge by default
    },
  },
  lastTasksFetchedAt: 0,
  lastExecutionsFetchedAt: 0,
  tasksViewSelectedDate: getTodayMidnight(),
};

/**
 * Masked, change-detected upsert of `incoming` onto the task map, mutating the immer draft.
 *
 * The single upsert shared by the two doors server task data uses to reach the store:
 * `upsertServerTasks` (partial, day-ranged lists) and `hydrateTodoTasks` (the authoritative todo
 * set, which additionally reconciles removals against `incomingIds`). Keeping it in one place is
 * what keeps the pending-resolution mask on BOTH doors — dropping it from either is how a task
 * the user just ticked reappears on screen. See pending-task-resolutions.ts.
 *
 * `incomingIds` is collected AFTER masking on purpose: a pending-deleted task is absent from the
 * masked list, so it is also absent from the id set and the caller's removal pass drops it.
 */
function upsertMaskedTasks(
  state: { tasks: Record<string, HydratedUserTask> },
  incoming: HydratedUserTask[],
): { hasChanges: boolean; incomingIds: Set<string> } {
  const masked = applyPendingTaskResolutions(incoming);
  const incomingIds = new Set<string>();
  let hasChanges = false;

  for (const task of masked) {
    incomingIds.add(task.id);
    const existing = state.tasks[task.id];
    if (JSON.stringify(existing) !== JSON.stringify(task)) {
      hasChanges = true;
      state.tasks[task.id] = task;
    }
  }

  return { hasChanges, incomingIds };
}

export const createUserTasksSlice: StateCreator<
  CedarStore,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  UserTasksSlice
> = (set, get) => ({
  ...initialUserTasksState,

  // Task Management
  setTasks: (tasks) =>
    set(
      (state) => {
        let hasChanges = false;
        Object.entries(tasks).forEach(([taskId, taskData]) => {
          const existing = state.tasks[taskId];
          if (JSON.stringify(existing) !== JSON.stringify(taskData)) {
            hasChanges = true;
            state.tasks[taskId] = taskData;
          }
        });
        if (hasChanges) {
          state.lastTasksFetchedAt = Date.now();
        }
        if (!hasChanges) return;
      },
      false,
      'userTasks/setTasks',
    ),

  upsertServerTasks: (incoming) =>
    set(
      (state) => {
        const { hasChanges } = upsertMaskedTasks(state, incoming);
        if (hasChanges) {
          state.lastTasksFetchedAt = Date.now();
        }
      },
      false,
      'userTasks/upsertServerTasks',
    ),

  hydrateTodoTasks: (incoming) =>
    set(
      (state) => {
        const { hasChanges, incomingIds } = upsertMaskedTasks(state, incoming);

        // Reconcile removals: drop any todo task the authoritative list no longer contains. Only
        // status 'todo' is reconciled — done/deleted rows held for other surfaces are left alone.
        // This is the ONLY difference from `upsertServerTasks`, and it is why a partial list must
        // not come through here.
        let removedAny = false;
        for (const taskId of Object.keys(state.tasks)) {
          const t = state.tasks[taskId];
          if (t.status === 'todo' && !incomingIds.has(taskId)) {
            removedAny = true;
            delete state.tasks[taskId];
          }
        }

        if (hasChanges || removedAny) {
          state.lastTasksFetchedAt = Date.now();
        }
      },
      false,
      'userTasks/hydrateTodoTasks',
    ),

  getTask: (taskId) => get().tasks[taskId],
  getTasks: () => Object.values(get().tasks),
  getTasksByStatus: (status) => Object.values(get().tasks).filter((task) => task.status === status),
  removeTask: (taskId) =>
    set(
      (state) => {
        delete state.tasks[taskId];
        state.lastTasksFetchedAt = Date.now(); // Trigger recomputation
      },
      false,
      'userTasks/removeTask',
    ),
  removeEmailTasksForConversation: (conversationId) =>
    set(
      (state) => {
        // Remove from userTasksSlice immediately
        Object.keys(state.tasks).forEach((taskId) => {
          const task = state.tasks[taskId];
          if (task.conversationId === conversationId && task.taskOutput?.kind === 'email') {
            delete state.tasks[taskId];
          }
        });
        state.lastTasksFetchedAt = Date.now(); // Trigger recomputation

        // Remove from conversationsSlice immediately
        const conversation = state.conversations[conversationId];
        if (conversation?.data?.userTasks) {
          // ConversationUserTask is the raw type in conversation.data.userTasks (without hydrated fields)
          const filteredUserTasks = conversation.data.userTasks.filter(
            (task) => task.taskOutput?.kind !== 'email',
          );

          // Update conversation data without email tasks
          state.conversations[conversationId] = {
            ...conversation,
            data: {
              ...conversation.data,
              userTasks: filteredUserTasks,
            },
          };
        }
      },
      false,
      'userTasks/removeEmailTasksForConversation',
    ),
  restoreTask: (taskId, task) =>
    set(
      (state) => {
        state.tasks[taskId] = task;
        state.lastTasksFetchedAt = Date.now(); // Trigger recomputation
      },
      false,
      'userTasks/restoreTask',
    ),
  updateTask: (taskId, updates) =>
    set(
      (state) => {
        const existing = state.tasks[taskId];
        if (existing) {
          state.tasks[taskId] = { ...existing, ...updates };
          state.lastTasksFetchedAt = Date.now(); // Trigger recomputation
        }
      },
      false,
      'userTasks/updateTask',
    ),
  markTaskAsRead: (taskId) =>
    set(
      (state) => {
        const existing = state.tasks[taskId];
        if (existing) {
          state.tasks[taskId] = { ...existing, isRead: true };
          state.lastTasksFetchedAt = Date.now(); // Trigger recomputation
        }
      },
      false,
      'userTasks/markTaskAsRead',
    ),
  clearTasks: () =>
    set(
      (state) => {
        state.tasks = {};
        state.lastTasksFetchedAt = 0;
      },
      false,
      'userTasks/clearTasks',
    ),

  // Snooze Dialog Management
  openSnoozeDialog: (taskId) =>
    set(
      (state) => {
        state.snoozeDialogOpen = true;
        state.snoozeDialogTaskId = taskId;
      },
      false,
      'userTasks/openSnoozeDialog',
    ),
  closeSnoozeDialog: () =>
    set(
      (state) => {
        state.snoozeDialogOpen = false;
        state.snoozeDialogTaskId = null;
      },
      false,
      'userTasks/closeSnoozeDialog',
    ),

  // Task board bulk selection (x / shift+x)
  setTaskSelection: (taskIds) =>
    set(
      (state) => {
        // Dedupe, preserving insertion order (matches mailSlice's bulk-selection semantics).
        state.taskSelection = [...new Set(taskIds)];
      },
      false,
      'userTasks/setTaskSelection',
    ),
  toggleTaskSelection: (taskId) =>
    set(
      (state) => {
        state.taskSelection = state.taskSelection.includes(taskId)
          ? state.taskSelection.filter((id) => id !== taskId)
          : [...state.taskSelection, taskId];
      },
      false,
      'userTasks/toggleTaskSelection',
    ),
  clearTaskSelection: () =>
    set(
      (state) => {
        state.taskSelection = [];
        state.taskSelectionAnchorId = null;
      },
      false,
      'userTasks/clearTaskSelection',
    ),
  setTaskSelectionAnchorId: (taskId) =>
    set(
      (state) => {
        state.taskSelectionAnchorId = taskId;
      },
      false,
      'userTasks/setTaskSelectionAnchorId',
    ),
  isTaskSelected: (taskId) => get().taskSelection.includes(taskId),

  setTaskExecuting: (taskId, executing) =>
    set(
      (state) => {
        const has = state.executingTaskIds.includes(taskId);
        if (executing && !has) state.executingTaskIds.push(taskId);
        else if (!executing && has)
          state.executingTaskIds = state.executingTaskIds.filter((id) => id !== taskId);
      },
      false,
      'userTasks/setTaskExecuting',
    ),
  isTaskExecuting: (taskId) => get().executingTaskIds.includes(taskId),

  // Scheduled Executions Management
  setScheduledExecutions: (executions) =>
    set(
      (state) => {
        let hasChanges = false;
        Object.entries(executions).forEach(([runId, executionData]) => {
          const existing = state.scheduledExecutions[runId];
          if (JSON.stringify(existing) !== JSON.stringify(executionData)) {
            hasChanges = true;
            state.scheduledExecutions[runId] = executionData;
          }
        });
        if (hasChanges) {
          state.lastExecutionsFetchedAt = Date.now();
        }
        if (!hasChanges) return;
      },
      false,
      'userTasks/setScheduledExecutions',
    ),

  getScheduledExecution: (runId) => get().scheduledExecutions[runId],
  getScheduledExecutions: () => Object.values(get().scheduledExecutions),
  getScheduledExecutionsByStatus: (status) =>
    Object.values(get().scheduledExecutions).filter((execution) => execution.status === status),
  removeScheduledExecution: (runId) =>
    set(
      (state) => {
        delete state.scheduledExecutions[runId];
        state.lastExecutionsFetchedAt = Date.now(); // Trigger recomputation
      },
      false,
      'userTasks/removeScheduledExecution',
    ),
  updateScheduledExecution: (runId, updates) =>
    set(
      (state) => {
        const existing = state.scheduledExecutions[runId];
        if (existing) {
          state.scheduledExecutions[runId] = { ...existing, ...updates };
          state.lastExecutionsFetchedAt = Date.now(); // Trigger recomputation
        }
      },
      false,
      'userTasks/updateScheduledExecution',
    ),
  restoreScheduledExecution: (runId, execution) =>
    set(
      (state) => {
        state.scheduledExecutions[runId] = execution;
        state.lastExecutionsFetchedAt = Date.now(); // Trigger recomputation
      },
      false,
      'userTasks/restoreScheduledExecution',
    ),
  clearScheduledExecutions: () =>
    set(
      (state) => {
        state.scheduledExecutions = {};
        state.lastExecutionsFetchedAt = 0;
      },
      false,
      'userTasks/clearScheduledExecutions',
    ),

  // Task Labels Management
  setTaskLabels: (labels) =>
    set(
      (state) => {
        state.taskLabels = labels;
      },
      false,
      'userTasks/setTaskLabels',
    ),

  getTaskLabels: () => get().taskLabels,

  // Date-based task queries
  getTasksForDate: (date: Date) => {
    const startOfDay = new Date(date);
    startOfDay.setHours(0, 0, 0, 0);
    const endOfDay = new Date(date);
    endOfDay.setHours(23, 59, 59, 999);

    return Object.values(get().tasks).filter((task) => {
      if (task.status !== 'todo') return false;
      const dueDate = task.dueDate ? new Date(task.dueDate) : null;
      if (!dueDate) return false;
      return dueDate >= startOfDay && dueDate <= endOfDay;
    });
  },

  getTasksBeforeDate: (date: Date) => {
    const startOfDay = new Date(date);
    startOfDay.setHours(0, 0, 0, 0);

    return Object.values(get().tasks).filter((task) => {
      if (task.status !== 'todo') return false;
      const dueDate = task.dueDate ? new Date(task.dueDate) : null;
      if (!dueDate) return false;
      return dueDate < startOfDay;
    });
  },

  getTasksAfterDate: (date: Date) => {
    const endOfDay = new Date(date);
    endOfDay.setHours(23, 59, 59, 999);

    return Object.values(get().tasks).filter((task) => {
      if (task.status !== 'todo') return false;
      const dueDate = task.dueDate ? new Date(task.dueDate) : null;
      if (!dueDate) return false;
      return dueDate > endOfDay;
    });
  },

  getScheduledExecutionsAfterDate: (date: Date) => {
    const endOfDay = new Date(date);
    endOfDay.setHours(23, 59, 59, 999);
    const now = new Date();

    return Object.values(get().scheduledExecutions).filter((execution) => {
      if (execution.status !== 'pending') return false;
      if (!execution.scheduledFor) return false;
      const scheduledDate = new Date(execution.scheduledFor);
      // Must be in the future AND after the specified date
      return scheduledDate > now && scheduledDate > endOfDay;
    });
  },

  // Date key map management
  setTaskIdsByDateKey=[redacted] =>
    set(
      (state) => {
        // Only update if the map actually changed
        const hasChanges = JSON.stringify(state.taskIdsByDateKey) !== JSON.stringify(map);
        if (hasChanges) {
          state.taskIdsByDateKey = map;
          state.lastTasksFetchedAt = Date.now(); // Trigger component re-renders
        }
      },
      false,
      'userTasks/setTaskIdsByDateKey',
    ),

  setTaskIdsForDateKey=[redacted], taskIds) =>
    set(
      (state) => {
        // Only update if this bucket actually changed
        const existing = state.taskIdsByDateKey[dateKey];
        const hasChanges = JSON.stringify(existing) !== JSON.stringify(taskIds);
        if (hasChanges) {
          state.taskIdsByDateKey[dateKey] = taskIds;
          state.lastTasksFetchedAt = Date.now(); // Trigger component re-renders
        }
      },
      false,
      'userTasks/setTaskIdsForDateKey',
    ),

  setExecutionIdsByDateKey=[redacted] =>
    set(
      (state) => {
        state.executionIdsByDateKey = map;
      },
      false,
      'userTasks/setExecutionIdsByDateKey',
    ),

  getTaskIdsForDateKey=[redacted] => get().taskIdsByDateKey[dateKey] || [],

  getExecutionIdsForDateKey=[redacted] => get().executionIdsByDateKey[dateKey] || [],

  getTasksForDateKey=[redacted] => {
    const taskIds = get().taskIdsByDateKey[dateKey] || [];
    const tasks = get().tasks;
    return taskIds.map((id) => tasks[id]).filter(Boolean);
  },

  getExecutionsForDateKey=[redacted] => {
    const executionIds = get().executionIdsByDateKey[dateKey] || [];
    const executions = get().scheduledExecutions;
    return executionIds.map((id) => executions[id]).filter(Boolean);
  },

  // Sorting Configuration Management
  setSortingConfiguration: (config) =>
    set(
      (state) => {
        state.sortingConfiguration = config;
      },
      false,
      'userTasks/setSortingConfiguration',
    ),

  getSortingConfiguration: () => get().sortingConfiguration,

  updateAttributeOrdering: (attribute, values) =>
    set(
      (state) => {
        state.sortingConfiguration.ordering[attribute] = values;
      },
      false,
      'userTasks/updateAttributeOrdering',
    ),

  toggleBadgeVisibility: (attribute) =>
    set(
      (state) => {
        if (!state.sortingConfiguration.badgeVisibility) {
          state.sortingConfiguration.badgeVisibility = {};
        }
        const currentVisibility = state.sortingConfiguration.badgeVisibility[attribute];
        // If undefined (default visible), set to false. If false, set to true. If true, set to false.
        state.sortingConfiguration.badgeVisibility[attribute] =
          currentVisibility === undefined ? false : !currentVisibility;
      },
      false,
      'userTasks/toggleBadgeVisibility',
    ),

  // Tasks View Date Navigation
  setTasksViewSelectedDate: (date) =>
    set(
      (state) => {
        state.tasksViewSelectedDate = date;
      },
      false,
      'userTasks/setTasksViewSelectedDate',
    ),

  navigateTasksViewDateLeft: () =>
    set(
      (state) => {
        const today = getTodayMidnight();
        const currentDate = new Date(state.tasksViewSelectedDate);
        currentDate.setHours(0, 0, 0, 0);

        // Calculate days from today
        const diffDays = Math.round(
          (currentDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
        );

        // If showing scheduled (beyond +3), go to +3
        if (diffDays > 3) {
          const newDate = new Date(today);
          newDate.setDate(today.getDate() + 3);
          state.tasksViewSelectedDate = newDate;
          return;
        }

        // If already showing previous (< -3), stay there (already at leftmost)
        if (diffDays < -3) {
          return;
        }

        // If at -3, go to previous (set to -4 days)
        if (diffDays === -3) {
          const newDate = new Date(today);
          newDate.setDate(today.getDate() - 4);
          state.tasksViewSelectedDate = newDate;
          return;
        }

        // Navigate to previous day
        const newDate = new Date(currentDate);
        newDate.setDate(currentDate.getDate() - 1);
        state.tasksViewSelectedDate = newDate;
      },
      false,
      'userTasks/navigateTasksViewDateLeft',
    ),

  navigateTasksViewDateRight: () =>
    set(
      (state) => {
        const today = getTodayMidnight();
        const currentDate = new Date(state.tasksViewSelectedDate);
        currentDate.setHours(0, 0, 0, 0);

        // Calculate days from today
        const diffDays = Math.round(
          (currentDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
        );

        // If showing previous (< -3), go to -3
        if (diffDays < -3) {
          const newDate = new Date(today);
          newDate.setDate(today.getDate() - 3);
          state.tasksViewSelectedDate = newDate;
          return;
        }

        // If already at scheduled (beyond +3), stay there (already at rightmost)
        if (diffDays > 3) {
          return;
        }

        // If at +3, go to scheduled (set to +4 days)
        if (diffDays === 3) {
          const newDate = new Date(today);
          newDate.setDate(today.getDate() + 4);
          state.tasksViewSelectedDate = newDate;
          return;
        }

        // Navigate to next day
        const newDate = new Date(currentDate);
        newDate.setDate(currentDate.getDate() + 1);
        state.tasksViewSelectedDate = newDate;
      },
      false,
      'userTasks/navigateTasksViewDateRight',
    ),

  // Utility
  clearUserTasksSlice: () => set(() => initialUserTasksState, false, 'userTasks/clear'),
});