conversationsSidebarSlice.ts13.6 KBView on GitHub
import type { CedarStore } from '@/modules/store/CedarStoreTypes';
import type { StateCreator } from 'zustand';
import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { ColumnFilter, ColumnSort } from '@/modules/crm/store/crmSlice';
import { normalizeSortDimension } from '@/modules/crm/store/crmSlice';

const STORAGE_KEY=[redacted];
const PERSIST_SCHEMA_VERSION = 2;

export type SidebarGroupBy =
  | { kind: 'column'; columnId: string }
  | { kind: 'sentinel'; value: 'starredAndResponse' | 'awaitingResponse' | 'none' };

export interface ConversationsSidebarSlice {
  conversationsSidebar: {
    groupBy: SidebarGroupBy;
    columns: CanvasFilterSortConfiguration;
    searchQuery: string;
    collapsedGroupKeys: Record<string, boolean>;
  };
  // Sort setters
  setSidebarColumnSort: (columnId: string, sort: ColumnSort | undefined) => void;
  toggleSidebarColumnSort: (columnId: string) => void;
  clearSidebarColumnSort: (columnId: string) => void;
  clearSidebarAllSorts: () => void;
  setSidebarColumnSortCustomOrder: (columnId: string, order: (string | null)[]) => void;
  // Filter setters
  setSidebarColumnFilter: (columnId: string, filter: ColumnFilter | undefined) => void;
  clearSidebarColumnFilter: (columnId: string) => void;
  clearSidebarAllColumnFilters: () => void;
  // Group / search / collapse
  setSidebarGroupBy: (groupBy: SidebarGroupBy) => void;
  setSidebarSearchQuery: (q: string) => void;
  toggleSidebarGroupCollapsed: (key=[redacted] => void;
}

export type PersistedShapeV2 = {
  schema: 2;
  groupBy: SidebarGroupBy;
  columns: CanvasFilterSortConfiguration;
  collapsedGroupKeys: Record<string, boolean>;
};

const DEFAULT_GROUP_BY: SidebarGroupBy = { kind: 'sentinel', value: 'starredAndResponse' };

const DEFAULTS: PersistedShapeV2 = {
  schema: PERSIST_SCHEMA_VERSION,
  groupBy: DEFAULT_GROUP_BY,
  columns: {},
  collapsedGroupKeys: {},
};

// ──────────────────────────────────────────────────────────────────────────────
// v1 → v2 migration
// ──────────────────────────────────────────────────────────────────────────────

// Mapping from v1 string groupBy keys to v2 SidebarGroupBy
function migrateGroupBy(raw: unknown): SidebarGroupBy {
  if (typeof raw === 'object' && raw !== null) {
    const r = raw as { kind?: string; columnId?: string; value?: string };
    if (r.kind === 'column' && typeof r.columnId === 'string') {
      return { kind: 'column', columnId: r.columnId };
    }
    if (
      r.kind === 'sentinel' &&
      (r.value === 'starredAndResponse' || r.value === 'awaitingResponse' || r.value === 'none')
    ) {
      return { kind: 'sentinel', value: r.value };
    }
  }
  if (typeof raw !== 'string') return DEFAULT_GROUP_BY;
  switch (raw) {
    case 'starredAndResponse':
    case 'awaitingResponse':
    case 'none':
      return { kind: 'sentinel', value: raw };
    case 'starred':
      // v1's 'starred' is closest to 'starredAndResponse' (which still surfaces starred first)
      return { kind: 'sentinel', value: 'starredAndResponse' };
    case 'status':
    case 'priority':
    case 'aopId':
      return { kind: 'column', columnId: raw };
    case 'owner':
      return { kind: 'column', columnId: 'primaryUser' };
    case 'aop':
      return { kind: 'column', columnId: 'aopId' };
    default:
      return DEFAULT_GROUP_BY;
  }
}

// Translate v1 sortBy enum into a one-entry columns config.
function v1SortByToColumnsEntry(sortBy: unknown): {
  columnId: string;
  sort: ColumnSort;
} | null {
  if (typeof sortBy !== 'string') return null;
  switch (sortBy) {
    case 'lastActivity':
      return {
        columnId: 'lastContactedAt',
        sort: { active: true, direction: 'desc', priority: 0 },
      };
    case 'dueDateAsc':
      return {
        columnId: 'nextStepDate',
        sort: { active: true, direction: 'asc', priority: 0 },
      };
    case 'name':
      return { columnId: 'name', sort: { active: true, direction: 'asc', priority: 0 } };
    case 'dealValueDesc':
      return {
        columnId: 'dealValue',
        sort: { active: true, direction: 'desc', priority: 0 },
      };
    default:
      return null;
  }
}

function migrateV1Filters(filters: unknown): CanvasFilterSortConfiguration {
  if (!filters || typeof filters !== 'object') return {};
  const f = filters as {
    status?: string[];
    priority?: string[];
    aopIds?: string[];
    hasOpenTasks?: boolean;
    // onlyStarred + hasSlackChannel intentionally dropped — no server-side equivalent
  };
  const next: CanvasFilterSortConfiguration = {};
  if (f.status?.length) next.status = { filter: { selected: f.status } };
  if (f.priority?.length) next.priority = { filter: { selected: f.priority } };
  if (f.aopIds?.length) next.aopId = { filter: { selected: f.aopIds } };
  if (f.hasOpenTasks) {
    // currentTasks supports binaryChoice via compute-canvas-filters → filters.hasTodoTasks
    next.currentTasks = { filter: { binaryChoice: 'true' } };
  }
  return next;
}

export function migrateV1Persisted(parsed: Record<string, unknown>): PersistedShapeV2 {
  const groupBy = migrateGroupBy(parsed.groupBy);
  const columns = migrateV1Filters(parsed.filters);
  const sortEntry = v1SortByToColumnsEntry(parsed.sortBy);
  if (sortEntry) {
    columns[sortEntry.columnId] = {
      ...columns[sortEntry.columnId],
      sort: sortEntry.sort,
    };
  }
  return {
    schema: PERSIST_SCHEMA_VERSION,
    groupBy,
    columns,
    collapsedGroupKeys:
      (parsed.collapsedGroupKeys as Record<string, boolean> | undefined) ?? {},
  };
}

/**
 * Rewrite any legacy `sort.sortDimension` (e.g. the old 'strength') into a
 * currently-valid value so rehydrated columns never carry a dimension the
 * backend rejects. Returns the same reference when nothing changed.
 */
function sanitizeColumns(columns: CanvasFilterSortConfiguration): CanvasFilterSortConfiguration {
  let changed = false;
  const next: CanvasFilterSortConfiguration = {};
  for (const [columnId, col] of Object.entries(columns)) {
    const dimension = col.sort?.sortDimension;
    if (dimension === undefined) {
      next[columnId] = col;
      continue;
    }
    const normalized = normalizeSortDimension(dimension);
    if (normalized === dimension) {
      next[columnId] = col;
      continue;
    }
    changed = true;
    next[columnId] = { ...col, sort: { ...col.sort!, sortDimension: normalized } };
  }
  return changed ? next : columns;
}

export function readPersisted(): PersistedShapeV2 {
  if (typeof window === 'undefined') return DEFAULTS;
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return DEFAULTS;
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    if (parsed.schema === PERSIST_SCHEMA_VERSION) {
      return {
        schema: PERSIST_SCHEMA_VERSION,
        groupBy: migrateGroupBy(parsed.groupBy),
        columns: sanitizeColumns(
          (parsed.columns as CanvasFilterSortConfiguration | undefined) ?? DEFAULTS.columns,
        ),
        collapsedGroupKeys:
          (parsed.collapsedGroupKeys as Record<string, boolean> | undefined) ??
          DEFAULTS.collapsedGroupKeys,
      };
    }
    return migrateV1Persisted(parsed);
  } catch {
    return DEFAULTS;
  }
}

function writePersisted(s: Omit<PersistedShapeV2, 'schema'>) {
  if (typeof window === 'undefined') return;
  try {
    const payload: PersistedShapeV2 = { schema: PERSIST_SCHEMA_VERSION, ...s };
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
  } catch {
    /* swallow quota errors */
  }
}

// ──────────────────────────────────────────────────────────────────────────────
// Slice
// ──────────────────────────────────────────────────────────────────────────────

export const createConversationsSidebarSlice: StateCreator<
  CedarStore,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  ConversationsSidebarSlice
> = (set, get) => {
  const initial = readPersisted();

  const persist = () => {
    const s = get().conversationsSidebar;
    writePersisted({
      groupBy: s.groupBy,
      columns: s.columns,
      collapsedGroupKeys: s.collapsedGroupKeys,
    });
  };

  return {
    conversationsSidebar: {
      groupBy: initial.groupBy,
      columns: initial.columns,
      collapsedGroupKeys: initial.collapsedGroupKeys,
      searchQuery: '',
    },

    setSidebarColumnSort: (columnId, sort) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId] ?? {};
          state.conversationsSidebar.columns[columnId] = { ...col, sort };
        },
        false,
        'conversationsSidebar/setColumnSort',
      );
      persist();
    },

    toggleSidebarColumnSort: (columnId) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId] ?? {};
          const current = col.sort;
          if (!current?.active) {
            // Compute next priority = (max active priority) + 1, so new sorts append.
            const maxPriority = Object.values(state.conversationsSidebar.columns).reduce(
              (max, c) => (c.sort?.active ? Math.max(max, c.sort.priority) : max),
              -1,
            );
            state.conversationsSidebar.columns[columnId] = {
              ...col,
              sort: {
                active: true,
                direction: current?.direction ?? 'asc',
                priority: maxPriority + 1,
                order: current?.order,
                sortDimension: current?.sortDimension,
              },
            };
          } else if (current.direction === 'asc') {
            state.conversationsSidebar.columns[columnId] = {
              ...col,
              sort: { ...current, direction: 'desc' },
            };
          } else {
            state.conversationsSidebar.columns[columnId] = {
              ...col,
              sort: { ...current, active: false },
            };
          }
        },
        false,
        'conversationsSidebar/toggleColumnSort',
      );
      persist();
    },

    clearSidebarColumnSort: (columnId) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId];
          if (!col) return;
          state.conversationsSidebar.columns[columnId] = { ...col, sort: undefined };
        },
        false,
        'conversationsSidebar/clearColumnSort',
      );
      persist();
    },

    clearSidebarAllSorts: () => {
      set(
        (state) => {
          for (const id of Object.keys(state.conversationsSidebar.columns)) {
            const col = state.conversationsSidebar.columns[id];
            if (col?.sort)
              state.conversationsSidebar.columns[id] = { ...col, sort: undefined };
          }
        },
        false,
        'conversationsSidebar/clearAllSorts',
      );
      persist();
    },

    setSidebarColumnSortCustomOrder: (columnId, order) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId];
          if (!col?.sort) return;
          state.conversationsSidebar.columns[columnId] = {
            ...col,
            sort: { ...col.sort, order },
          };
        },
        false,
        'conversationsSidebar/setColumnSortCustomOrder',
      );
      persist();
    },

    setSidebarColumnFilter: (columnId, filter) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId] ?? {};
          state.conversationsSidebar.columns[columnId] = { ...col, filter };
        },
        false,
        'conversationsSidebar/setColumnFilter',
      );
      persist();
    },

    clearSidebarColumnFilter: (columnId) => {
      set(
        (state) => {
          const col = state.conversationsSidebar.columns[columnId];
          if (!col) return;
          state.conversationsSidebar.columns[columnId] = { ...col, filter: undefined };
        },
        false,
        'conversationsSidebar/clearColumnFilter',
      );
      persist();
    },

    clearSidebarAllColumnFilters: () => {
      set(
        (state) => {
          for (const id of Object.keys(state.conversationsSidebar.columns)) {
            const col = state.conversationsSidebar.columns[id];
            if (col?.filter)
              state.conversationsSidebar.columns[id] = { ...col, filter: undefined };
          }
        },
        false,
        'conversationsSidebar/clearAllColumnFilters',
      );
      persist();
    },

    setSidebarGroupBy: (groupBy) => {
      set(
        (state) => {
          state.conversationsSidebar.groupBy = groupBy;
        },
        false,
        'conversationsSidebar/setGroupBy',
      );
      persist();
    },

    setSidebarSearchQuery: (q) =>
      set(
        (state) => {
          state.conversationsSidebar.searchQuery = q;
        },
        false,
        'conversationsSidebar/setSearchQuery',
      ),

    toggleSidebarGroupCollapsed: (key) => {
      set(
        (state) => {
          const current = state.conversationsSidebar.collapsedGroupKeys[key] ?? false;
          state.conversationsSidebar.collapsedGroupKeys[key] = !current;
        },
        false,
        'conversationsSidebar/toggleGroupCollapsed',
      );
      persist();
    },
  };
};