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