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'),
});