mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
- Colunas responsivas ao painel via @container/territory-list (P0→P3), sem scroll horizontal
- Contagem de municípios no nome ("Irecê (20)"); % e votos absorvidos na célula 2022 (hover = válidos)
- Célula 2026 no detalhe de Municípios (valor central + %, faixa de cenários no hover, leitura)
- Colunas de rede só leitura: Assessor, Liderança, Dobradinha (para todo o staff)
- Cobertura oculta por padrão no seletor; labels curtos (2022/2026/Captura/Cobertura)
- loadTerritoryOverview devolve { rows, references }; rollup puro com soma por cenário, hasEstimate e uniões de rede
169 lines
6 KiB
TypeScript
169 lines
6 KiB
TypeScript
/**
|
|
* 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',
|
|
] as const
|
|
|
|
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
|
|
|
export type CampaignHiddenColumns = Partial<Record<CampaignListId, string[]>>
|
|
|
|
/**
|
|
* 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<Record<CampaignListId, readonly string[]>> = {
|
|
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'],
|
|
}
|
|
|
|
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 = <Column extends HideableColumn>(
|
|
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))
|
|
}
|