/** * B17 — which columns of a `CampaignTable` the actor chose to hide. * * Pure and client-safe: the picker (client) serializes, the list surfaces * (RSC) parse and filter. Persistence is a cookie rather than `localStorage` * because the column array never crosses the server/client boundary * (`CampaignTableColumn.cell` is a function), so the server is the only place * that can drop a column — and a cookie is the only per-device preference the * server can read on the first paint, with no flash of the full table. */ export const CAMPAIGN_COLUMNS_COOKIE = 'campaign_columns' /** Scoped like the campaign session cookie: the picker only exists under /campanha. */ export const CAMPAIGN_COLUMNS_COOKIE_PATH = '/campanha' export const CAMPAIGN_COLUMNS_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 /** A malformed or oversized cookie shows the full table instead of guessing. */ const MAX_COOKIE_LENGTH = 2048 const CAMPAIGN_LIST_IDS = [ 'municipios', 'liderancas', 'dobradinhas', 'organizacoes', 'demandas', 'apoiadores', 'territorios', 'pessoas', // B197 — the advisors surface is a hand-rolled client table (no // `CampaignTable`), but it joined the picker with the same cookie key. 'assessores', ] as const export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number] export type CampaignHiddenColumns = Partial> /** * What one list surface needs to run the picker, as ONE value. The id is what * the cookie is keyed by and what the picker writes back, so the reader and * the table must agree on it — as two props they could disagree between two * perfectly valid ids, and the only symptom would be a picker that does nothing. */ export type CampaignColumnVisibility = { listId: CampaignListId hiddenColumnIds: readonly string[] } /** Serializable column metadata for `CampaignColumnPicker` (B17 / B137). */ export type CampaignColumnPickerColumn = { id: string label: string mandatory?: boolean } export const toCampaignColumnPickerColumns = ( columns: readonly { id: string; label: string; mandatory?: boolean }[], ): CampaignColumnPickerColumn[] => columns.map(({ id, label, mandatory }) => ({ id, label, mandatory })) /** * `listId:colA~colB|otherList:colC`. Every character here is legal in a * cookie value (RFC 6265 forbids comma, semicolon, whitespace, quotes and * backslash), so nothing is percent-encoded — which matters because * `document.cookie` and Next's `cookies()` disagree about decoding. */ const LIST_SEPARATOR = '|' const LIST_ID_SEPARATOR = ':' const COLUMN_SEPARATOR = '~' const EXPLICIT_EMPTY_COLUMNS = '__none__' const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/ const DEFAULT_HIDDEN_COLUMN_IDS: Partial> = { municipios: ['goalCoverage', 'lastSignal'], // B175 — Cobertura is the deepest rung (P3) and only shows when the actor // opts in from the picker, mirroring the municipios precedent. territorios: ['cobertura'], // B197 — the person lists open without the email column; the channel of the // day is phone/WhatsApp. Anyone can re-enable it from the picker, and a // device with an existing choice keeps it (stored wins over default). liderancas: ['email'], // C129 — the "Nome de legenda" column starts hidden: the subline under the // name is the always-on display, and the column is the opt-in edit surface // (same email-column precedent: display carries the day, editing stays reachable). dobradinhas: ['email', 'ballotName'], pessoas: ['email'], apoiadores: ['email'], assessores: ['email'], } const isCampaignListId = (value: string): value is CampaignListId => CAMPAIGN_LIST_IDS.some((listId) => listId === value) export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHiddenColumns => { if (!raw || raw.length > MAX_COOKIE_LENGTH) return {} const parsed: CampaignHiddenColumns = {} for (const entry of raw.split(LIST_SEPARATOR)) { const separatorIndex = entry.indexOf(LIST_ID_SEPARATOR) if (separatorIndex <= 0) continue const listId = entry.slice(0, separatorIndex) if (!isCampaignListId(listId)) continue // An id matching nothing on the table is inert, so the length cap above is // the only bound worth having — a per-list one would also have to be // mirrored in `serialize`, or writing 41 and reading back 40 would differ. const encodedColumns = entry.slice(separatorIndex + 1) if (encodedColumns === EXPLICIT_EMPTY_COLUMNS) { parsed[listId] = [] continue } const columnIds = [ ...new Set( encodedColumns .split(COLUMN_SEPARATOR) .filter( (columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId), ), ), ] if (columnIds.length) parsed[listId] = columnIds } return parsed } export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string => CAMPAIGN_LIST_IDS.flatMap((listId) => { if (!Object.prototype.hasOwnProperty.call(value, listId)) return [] const columnIds = (value[listId] ?? []).filter( (columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId), ) const encodedColumns = columnIds.length ? columnIds.join(COLUMN_SEPARATOR) : EXPLICIT_EMPTY_COLUMNS return [`${listId}${LIST_ID_SEPARATOR}${encodedColumns}`] }).join(LIST_SEPARATOR) export const resolveCampaignColumnVisibility = ( listId: CampaignListId, hiddenColumns: CampaignHiddenColumns, ): CampaignColumnVisibility => ({ listId, hiddenColumnIds: Object.prototype.hasOwnProperty.call(hiddenColumns, listId) ? (hiddenColumns[listId] ?? []) : (DEFAULT_HIDDEN_COLUMN_IDS[listId] ?? []), }) /** * Records one toggle. Hiding is stored (never the visible set), so a column * shipped by a later item — E9's derived signals, E14's level — is visible to * everyone who never touched the picker, which is the contract those items * assumed. */ export const toggleHiddenColumn = ( hiddenColumnIds: readonly string[], columnId: string, visible: boolean, ): string[] => { const without = hiddenColumnIds.filter((id) => id !== columnId) return visible ? without : [...without, columnId] } type HideableColumn = { id: string; mandatory?: boolean } /** * A `mandatory` column is never dropped, whatever the cookie says — an actor * who lands with a stale cookie must not end up with a table he cannot read * (or, on `/campanha/municipios`, without the only linked name in the row). */ export const resolveVisibleColumns = ( columns: readonly Column[], hiddenColumnIds: readonly string[] | undefined, ): readonly Column[] => { if (!hiddenColumnIds?.length) return columns const hidden = new Set(hiddenColumnIds) return columns.filter((column) => column.mandatory || !hidden.has(column.id)) }