diff --git a/docs/plans/remodelar-atividades-para-agenda-impl.md b/docs/plans/remodelar-atividades-para-agenda-impl.md
new file mode 100644
index 00000000..b525ed55
--- /dev/null
+++ b/docs/plans/remodelar-atividades-para-agenda-impl.md
@@ -0,0 +1,157 @@
+# Impl: Remodelar atividades para a agenda (calendário)
+
+Status: aprovado
+Atualizado em: 2026-08-06
+Issue: #389
+Intenção: docs/plans/remodelar-atividades-para-agenda.md
+Appetite restante: ~1,5–2 dias eng (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** Remodelar o modelo/formulário de atividade para parecer compromisso de agenda — tempo + lugar + quem + tags livres — em ~1 minuto. Lista/detalhe/actions continuam usáveis; C15 traz o calendário por cima.
+- **O que NÃO negociar:** remover `origin` e `deadline`; renomear `kind` → `tags` (livres); status enxuto `confirmado | realizado | cancelado`; default `confirmado` + `startAt` obrigatório; remarcar horário com `deputyPresent` só coordinator/candidate; leader lockdown intacto; nenhuma nova collection de pessoa (Contact).
+- **O que reavaliar:** hipótese da intenção de "omnibox de associados" — o formulário atual já tem `RelationMultiSelect` para orgs e checkboxes de assessores; uma omnibox mista (assessor + liderança + organização) é caro e o produto não mostrou dor real — **rejeitada** nesta leva (ver Rabbit holes).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart TD
+ A[Migration: drop origin/deadline, kind→tags, status remap] --> B[schema activity.ts: tags string array, 3 statuses, startAt required]
+ B --> C[Activity.ts collection: tags field, remove origin/deadline/kind, status default confirmado]
+ C --> D[View models: tags/origin/deadline out, startAt always required]
+ D --> E[Form: tags input + autocomplete, no origin/deadline, status toggle só confirmado]
+ E --> F[Card/Omnibox/Dossier: tags badges, tag filter]
+ F --> G[E13 giros: tourStopRoleActivityTags, tags in stop draft]
+ G --> H[AI tools + notification hooks: adapt]
+ H --> I[deputyPresent write gate via beforeValidate]
+ I --> J[Gates: gate:fast + push]
+```
+
+**Opções consideradas:** A | B | C
+**Recomendação:** **B** — `text` com `hasMany: true` (Payload 3.x nativo, armazena `text[]`, indexável, queryable com `contains`) — porque é o mais simples que atende "tags livres + filtro" sem nova collection.
+**Rejeitadas:**
+
+- A — `array` com campo `text` aninhado: mais verboso, cada tag é uma row com id, desnecessário para tags simples.
+- C — `relationship` para nova collection `ActivityTag`: super-engineering, a intenção diz "a mesa inventa" — não precisa catálogo administrado.
+
+### Decisões de engenharia
+
+**1. Tags: `text` com `hasMany: true`**
+Opções: A) `text hasMany` | B) `array` com text field | C) relationship a nova collection
+Recomendação: **A** — Payload 3.x suporta nativamente, armazena como `text[]`, filtro `contains` funciona sem join.
+Rejeitadas: B — verbosidade desnecessária; C — nova collection sem necessidade, vai contra "tags livres".
+
+**2. Migração de `kind` → `tags`**
+Opções: A) migrar valores usando label map (comício → Comício) | B) migrar valores raw (comício) | C) descartar histórico
+Recomendação: **A** — preserva legibilidade; as labels já são pt-BR amigáveis e é o que a UI sempre mostrou.
+Rejeitadas: B — valores raw com underscore são feios como tag; C — perde histórico útil.
+
+**3. Remap de status antigos**
+Opções: A) `planejado` → `confirmado`, `rascunho` → `cancelado` | B) tudo → `confirmado` | C) tudo → `cancelado`
+Recomendação: **A** — planejados tinham data, viram confirmados; rascunhos eram esboços sem data, cancelados é o destino honesto.
+Rejeitadas: B — rascunhos sem data virariam "confirmados" sem data, quebrando a invariante; C — perde trabalho já planejado.
+
+**4. deputyPresent write gate**
+Opções: A) `beforeValidate` hook com role check | B) field-level access control | C) server action check
+Recomendação: **A** — `beforeValidate` tem acesso a `req.user`, `originalDoc`, e `data`; cobre todos os caminhos (admin + server action).
+Rejeitadas: B — field access não lê o doc atual facilmente; C — não cobre Payload admin.
+
+**5. Omnibox tag filter**
+Opções: A) filter por tag exata (`contains`) com autocomplete dos valores existentes | B) multi-tag filter | C) manter kind enum no filtro
+Recomendação: **A** — atende o job "filtrar por tipo de compromisso" sem inflar; multi-tag é rabbit hole para C15.
+Rejeitadas: B — scope creep; C — viola aceite de produto.
+
+**6. Formulário de tags (client)**
+Opções: A) tag input custom com datalist de valores conhecidos | B) Combobox multi-select | C) text field livre
+Recomendação: **A** — datalist HTML nativo dá autocomplete sem JS extra; o servidor passa a lista de tags conhecidas via props. Combobox multi é overkill para v1; text livre não dá autocomplete.
+Rejeitadas: B — dependência pesada para v1; C — sem autocomplete, viola opção B da intenção.
+
+### Componentes / mudanças
+
+- **Migration** (`20260806_XXXXXX_c14_remodel_activity_agenda`):
+ - Add `tags` column (`text[]`)
+ - Data: `tags = ARRAY[activity_kind_labels[kind]]` where kind is not null
+ - Remap: `planejado` → `confirmado`, `rascunho` → `cancelado`
+ - Drop `kind`, `origin`, `deadline` columns
+ - Add NOT NULL default `confirmado` on status
+ - JSON side: espelhar no `.json`
+- **`src/lib/schemas/activity.ts`**: remover `activityKinds`/`activityOrigins`/`activityKindLabels`/`activityOriginLabels`/`ActivityKind`/`ActivityOrigin`; `activityStatuses` → `['confirmado', 'realizado', 'cancelado']`; adicionar `MAX_ACTIVITY_TAGS`, `MAX_ACTIVITY_TAG_LENGTH`; schema `tags: z.array(z.string().trim().min(1).max(80)).max(20).optional()`; remover `kind`/`origin`/`deadline` do `activityFieldsSchema`; `activityCreateSchema` default status → `confirmado`; `tourStopDraftSchema` — `kind` → `tags`.
+- **`src/collections/Activity.ts`**: `tags` field (`text`, `hasMany: true`, `index: true`, `label: 'Tags'`); remover `kind`/`origin`/`deadline` fields; `status` options → 3 valores, default `'confirmado'`; `startAt` required; `activityStaffFieldSnapshot` atualizado; `validateActivitySchedule` adaptada (startAt sempre required); novo `validateDeputyPresentTimeGate` hook; `afterChange` remover check de `rascunho`; `defaultColumns` — trocar `kind` por `tags`.
+- **`src/utilities/activityViewModels.ts`**: remover `kind`/`origin`/`deadline` de todos os view models; adicionar `tags: string[]`; `activityListSelect`/`activityFormSelect`/`activityDetailContextSelect` trocar `kind`/`origin`/`deadline` por `tags`.
+- **`src/utilities/activityUi.ts`**: `ActivityListState.kind` → `.tag`; `buildActivityListWhere` — `{ tags: { contains: state.tag } }`; `isActivityKind` → `isActivityTag` (string validation); `parseActivityListParams` — `tag` instead of `kind`; `activityTabs` remover `rascunhos`.
+- **`src/utilities/activityOmnibox.ts`**: `kind` filter → `tag` filter; seeds from `activityKindLabels` → from existing tags (server-provided); chips `kind:` → `tag:`.
+- **`src/utilities/activityFormData.ts`**: remover `kind`/`origin`/`deadline` parsing; adicionar `tags` parsing (from `tagsJson` bounded JSON field).
+- **`src/components/campaign/activity/ActivityForm.tsx`**: remover `kind` select, `origin` select, `deadline` field; adicionar `ActivityTagsField` (tag input + datalist); status toggle: apenas `confirmado` para novo; `startAt` always required.
+- **`src/components/campaign/activity/ActivityCard.tsx`**: `activityKindLabels[activity.kind]` → render tags as small badges.
+- **`src/components/campaign/activity/ActivityStatusBadge.tsx`**: remover `rascunho`/`planejado` variants.
+- **`src/components/campaign/activity/ActivityFilters.tsx`**: kind → tag no omnibox.
+- **`src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx`**: remover rows de deadline/origin; adicionar tags display.
+- **`src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx`**: kind badge → tags badges.
+- **`src/components/campaign/municipality/MunicipalityDossier.tsx`**: `activityKindLabels[activity.kind]` → tags badges.
+- **`src/utilities/visit/visitPlannerViews.ts`**: `tourStopRoleActivityKind` → `tourStopRoleActivityTags: Record` (`ancora: ['Comício']`, `satelite: ['Reunião de apoio']`, `semente: ['Reunião de apoio']`).
+- **`src/components/campaign/tour/TourComposerForm.tsx`**: `kind` → `tags`; `ActivityKind` → `string[]`; `origin` → removed; `DEFAULT_STOP_ORIGIN` → removed.
+- **`src/utilities/ai/tools/buildCampaignLinks.ts`**: `kind: z.enum(activityKinds)` → `tag: z.string().optional()`; `status: z.enum(activityStatuses)` atualizado.
+- **`src/utilities/ai/campaignNavigationUrls.ts`**: `kind?: ActivityKind` → `tag?: string`; `activityKinds` reference removida; filter logic adapted.
+- **`src/utilities/notification/notificationEvents.ts`**: `afterChange` hook `becameVisible` check — `rascunho` → `confirmado` (since rascunho doesn't exist anymore, `becameVisible` is always true for creates).
+- **`src/utilities/access/activities.ts`**: adicionar `canUpdateDeputyPresentTime` para o gate do deputyPresent.
+- **`tests/unit/activityUi.unit.spec.ts`**: atualizar para 3 statuses, tag filter.
+- **`tests/unit/listOmniboxB128.unit.spec.ts`**: kind → tag.
+- **`tests/unit/codebaseConventions.unit.spec.ts`**: vocabulary guard — `actionPlan`/`plano de ação` já existe; adicionar `activityKind` como forbidden? Não — `kind` vira `tags`, não há vocabulário proibido novo.
+
+### Dados → forma
+
+N/A — remodel de modelo, não apresentação de dados novos.
+
+## Fases verificáveis
+
+1. **Tracer / schema+server** (~40% do appetite)
+ - Migration (drop/add columns + data migration)
+ - `src/lib/schemas/activity.ts` — schema remodel
+ - `src/collections/Activity.ts` — collection remodel + hooks
+ - `src/utilities/activityViewModels.ts` — view models
+ - `src/utilities/activityUi.ts` — list state/where/params
+ - `src/utilities/activityFormData.ts` — form data parsing
+ - `src/app/(campaign)/campanha/actions/activity.ts` — actions
+ - `src/utilities/visit/visitPlannerViews.ts` — giro tags
+ - `src/utilities/notification/notificationEvents.ts` — hook
+ - Unit tests: `activityUi`, `listOmniboxB128`
+ - `pnpm exec tsc --noEmit` + `pnpm test`
+
+2. **UI** (~40% do appetite)
+ - `ActivityForm.tsx` — tags input, no origin/deadline/kind
+ - `ActivityCard.tsx` — tags badges
+ - `ActivityStatusBadge.tsx` — 3 variants
+ - `ActivityOverviewTab.tsx` — no deadline/origin
+ - `ActivityFilters.tsx` / `activityOmnibox.ts` — tag filter
+ - `TourComposerForm.tsx` — tags per stop
+ - `MunicipalityDossier.tsx` — tags display
+ - AI tools — tag/status
+ - `pnpm lint` + `pnpm build` against local DB
+
+3. **Gates** (~20% do appetite)
+ - `pnpm gate:fast`
+ - `pnpm push -u origin HEAD`
+ - PR + auto-merge + `gh pr checks --watch --required`
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Omnibox mista de associados** (assessor + liderança + organização num só campo): a intenção menciona, mas o formulário atual já funciona com campos separados e o aceite não depende disso. Fica para C15 se a dor aparecer.
+- **Multi-tag filter**: o filtro de lista aceita UMA tag por vez. Multi-tag é scope creep para C15.
+- **Recorrência / sync Google**: fora de escopo (C16).
+- **FullCalendar / `/campanha/agenda`**: C15.
+- **Redesign do compositor de giros**: "só não quebrar sem destino" — adaptar os tipos, não mudar o fluxo.
+- **Catalog admin de tags**: a intenção rejeita explicitamente (opção C). Tags são livres.
+- **`activityKind` vocabulary guard**: não adicionar — o termo some naturalmente com a migration.
+
+## Riscos e mitigação
+
+- **Migration destrutiva em prod**: a migration dropa colunas. Revisar SQL antes do deploy. Dados de `kind` são migrados para `tags`; `origin`/`deadline` são descartados sem backup (a intenção aceita explicitamente).
+- **E13 giros quebrado**: `tourStopDraftSchema` muda de `kind` a `tags`. O giro passa a enviar `tags: ['Comício']` no lugar de `kind: 'comicio'`. Testar o fluxo end-to-end.
+- **Omnibox sem tags existentes**: o autocomplete de tags precisa de uma lista de valores já usados. Na listagem, isso vem do Payload (query distinta de tags existentes) ou de um endpoint. **Mitigação**: v1 usa `datalist` populado pelo servidor via props da página (tags distintas do `payload.find` na listagem). C15 traz um endpoint dedicado se preciso.
+- **deputyPresent gate hook**: o `beforeValidate` precisa ler `originalDoc.deputyPresent` e o role do `req.user`. Se o actor for advisor e tentar mudar `startAt`/`endAt` de atividade com `deputyPresent: true` → throw. Testar com fixtures de role.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto (tags livres, 3 statuses, origin/deadline removidos, deputyPresent gate)
+- [ ] Invariantes AGENTS/engineering-standards (transaction pattern, Contact convention, leader lockdown, naming)
+- [ ] Testes de domínio previstos: `activityUi.unit.spec.ts`, `listOmniboxB128.unit.spec.ts`, `codebaseConventions.unit.spec.ts`
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx
index 2b53a939..9735f23f 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
-import { activityOriginLabels } from '@/lib/schemas/activity'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
@@ -72,17 +71,21 @@ export const ActivityOverviewTab = ({
-
- Tipo de atividade *
-
- Selecione um tipo
- {Object.entries(activityKindLabels).map(([value, label]) => (
-
- {label}
-
- ))}
-
- {errorFor('kind') ? (
- {errorFor('kind')}
- ) : null}
-
-
- Status *
- {statusIsEditable ? (
- <>
- value && setStatus(value as typeof status)}
- variant="outline"
- className="flex w-full flex-wrap justify-start"
- aria-label="Status da atividade"
- >
- Planejado
- Rascunho
-
-
- Rascunhos não exigem data. Atividades planejadas exigem data e horário de
- início.
-
- >
- ) : (
-
- Status atual: {activity ? activityStatusLabels[activity.status] : ''}. Use as
- ações do detalhe para marcar como realizado ou cancelado.
-
- )}
-
-
-
- Origem da atividade
-
- {Object.entries(activityOriginLabels).map(([value, label]) => (
-
- {label}
-
- ))}
-
-
- Registra se a atividade nasceu da análise de dados, de um pedido de articulação ou
- de um compromisso político.
-
- {errorFor('origin') ? {errorFor('origin')} : null}
+
+
+ Tags
+
+
Descrição
-
-
- {checked ? (
-
- Origem desta parada
- onOriginChange(event.target.value as ActivityOrigin)}
- className="min-h-11 w-full"
- >
- {activityOrigins.map((value) => (
-
- {activityOriginLabels[value]}
-
- ))}
-
-
- ) : null}
)
}
@@ -166,7 +137,6 @@ export const TourComposerForm = ({
const [selectedSlugs, setSelectedSlugs] = useState(() =>
stops.filter((stop) => stop.suggested).map((stop) => stop.slug),
)
- const [origins, setOrigins] = useState>({})
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs])
@@ -177,11 +147,10 @@ export const TourComposerForm = ({
.filter((stop) => selectedSet.has(stop.slug))
.map((stop) => ({
municipality: stop.municipalityID,
- kind: stop.kind,
- origin: origins[stop.slug] ?? DEFAULT_STOP_ORIGIN,
+ tags: stop.tags,
})),
),
- [stops, selectedSet, origins],
+ [stops, selectedSet],
)
const overCap = selectedSlugs.length > MAX_TOUR_STOPS
@@ -209,11 +178,7 @@ export const TourComposerForm = ({
key={stop.slug}
stop={stop}
checked={selectedSet.has(stop.slug)}
- origin={origins[stop.slug] ?? DEFAULT_STOP_ORIGIN}
onToggle={(checked) => toggleStop(stop.slug, checked)}
- onOriginChange={(origin) =>
- setOrigins((current) => ({ ...current, [stop.slug]: origin }))
- }
/>
))}
@@ -231,8 +196,8 @@ export const TourComposerForm = ({
className="min-h-11"
/>
- Cada rascunho recebe este nome mais o município — assim o giro se lê como um conjunto na
- lista de Atividades.
+ Cada compromisso recebe este nome mais o município — assim o giro se lê como um conjunto
+ na lista de Atividades.
{fieldError(state.fieldErrors, 'tourName') ? (
{fieldError(state.fieldErrors, 'tourName')}
@@ -242,7 +207,7 @@ export const TourComposerForm = ({
Nota de janela política
- Vai na descrição de cada rascunho: com quem falar, o que já foi combinado, o que não
+ Vai na descrição de cada compromisso: com quem falar, o que já foi combinado, o que não
pode ser dito.
@@ -270,7 +235,7 @@ export const TourComposerForm = ({
? 'Selecione ao menos uma parada.'
: overCap
? `Um giro é um dia ou dois: escolha no máximo ${MAX_TOUR_STOPS} paradas.`
- : 'Os rascunhos entram sem data, com presença do candidato marcada. A data e o horário você define em cada atividade.'}
+ : 'Os compromissos entram com presença do candidato marcada. A data e o horário você define em cada atividade.'}
diff --git a/src/lib/schemas/activity.ts b/src/lib/schemas/activity.ts
index b4277725..d1564f91 100644
--- a/src/lib/schemas/activity.ts
+++ b/src/lib/schemas/activity.ts
@@ -22,59 +22,17 @@ export const ACTIVITY_RESULT_TOO_LONG_MESSAGE =
export const ACTIVITY_RESULT_STAFF_MESSAGE =
'Apenas a equipe da campanha pode registrar o resultado da atividade.'
-export const activityKinds = [
- 'caminhada',
- 'comicio',
- 'carreata',
- 'panfletagem',
- 'porta_a_porta',
- 'reuniao_apoio',
- 'lancamento',
- 'convencao',
- 'ato',
- 'entrevista',
- 'producao_conteudo',
- 'digital',
- 'outro',
-] as const
-export type ActivityKind = (typeof activityKinds)[number]
+/**
+ * C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
+ * No rigid taxonomy; the filter is the value. Max 20 tags, 80 chars each.
+ */
+export const MAX_ACTIVITY_TAGS = 20
+export const MAX_ACTIVITY_TAG_LENGTH = 80
-export const activityStatuses = [
- 'rascunho',
- 'planejado',
- 'confirmado',
- 'realizado',
- 'cancelado',
-] as const
+export const activityStatuses = ['confirmado', 'realizado', 'cancelado'] as const
+export type ActivityStatus = (typeof activityStatuses)[number]
-export const activityOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
-export type ActivityOrigin = (typeof activityOrigins)[number]
-
-export const activityOriginLabels: Record = {
- dado: 'Baseado em dado',
- pedido_broker: 'Pedido de broker',
- obrigacao_politica: 'Obrigação política',
-}
-
-export const activityKindLabels: Record = {
- caminhada: 'Caminhada',
- comicio: 'Comício',
- carreata: 'Carreata',
- panfletagem: 'Panfletagem',
- porta_a_porta: 'Porta a porta',
- reuniao_apoio: 'Reunião de apoio',
- lancamento: 'Lançamento',
- convencao: 'Convenção',
- ato: 'Ato',
- entrevista: 'Entrevista',
- producao_conteudo: 'Produção de conteúdo',
- digital: 'Digital',
- outro: 'Outro',
-}
-
-export const activityStatusLabels: Record<(typeof activityStatuses)[number], string> = {
- rascunho: 'Rascunho',
- planejado: 'Planejado',
+export const activityStatusLabels: Record = {
confirmado: 'Confirmado',
realizado: 'Realizado',
cancelado: 'Cancelado',
@@ -102,15 +60,18 @@ const taskSchema = z.object({
done: z.boolean().optional(),
})
+const activityTagsSchema = z
+ .array(z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH))
+ .max(MAX_ACTIVITY_TAGS)
+
const activityFieldsSchema = z.object({
title: z.string().trim().min(2).max(160),
- kind: z.enum(activityKinds),
+ tags: activityTagsSchema.optional(),
status: z.enum(activityStatuses).optional(),
description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(),
- deadline: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId,
locality: trimmedOptionalText(160),
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
@@ -118,23 +79,21 @@ const activityFieldsSchema = z.object({
responsible: positiveRelationshipId.optional(),
leadership: positiveRelationshipId.optional(),
tasks: z.array(taskSchema).optional(),
- origin: z.enum(activityOrigins).optional(),
})
const validateSchedule = (
data: {
- status?: (typeof activityStatuses)[number] | null
+ status?: ActivityStatus | null
startAt?: string | null
endAt?: string | null
},
context: z.RefinementCtx,
- mode: 'create' | 'patch',
+ requireStart: boolean,
) => {
- const status = data.status ?? (mode === 'create' ? 'rascunho' : undefined)
- if (status && status !== 'rascunho' && !data.startAt) {
+ if (requireStart && !data.startAt) {
context.addIssue({
code: 'custom',
- message: 'Informe a data e horário de início ao planejar ou confirmar a atividade.',
+ message: 'Informe a data e horário de início do compromisso.',
path: ['startAt'],
})
}
@@ -154,16 +113,15 @@ const validateSchedule = (
export const activityCreateSchema = activityFieldsSchema
.extend({
- status: z.enum(['rascunho', 'planejado']).default('rascunho'),
+ status: z.enum(['confirmado']).default('confirmado'),
})
.superRefine((data, context) => {
- validateSchedule(data, context, 'create')
+ validateSchedule(data, context, true)
})
.transform((data) => ({
...data,
- startAt: data.startAt ?? null,
- endAt: data.endAt ?? null,
- deadline: data.deadline ?? null,
+ endAt: data.endAt ?? undefined,
+ tags: data.tags ?? [],
}))
export const activityUpdateSchema = activityFieldsSchema
@@ -174,27 +132,24 @@ export const activityUpdateSchema = activityFieldsSchema
description: trimmedNullableText(4000),
startAt: z.string().datetime().nullable().optional(),
endAt: z.string().datetime().nullable().optional(),
- deadline: z.string().datetime().nullable().optional(),
responsible: positiveRelationshipId.nullable().optional(),
leadership: positiveRelationshipId.nullable().optional(),
status: z.enum(activityStatuses).optional(),
})
.superRefine((data, context) => {
- validateSchedule(data, context, 'patch')
+ validateSchedule(data, context, true)
})
export type ActivityCreateInput = z.input
export type ActivityUpdateInput = z.input
/**
- * E13 — one stop of a giro, as the composer submits it: which município, what
- * happens there, where the decision came from. The title is deliberately NOT
- * part of it: it is composed on the server from the município's own name, so a
- * client cannot label a draft after a município it did not pick.
+ * E13 — one stop of a giro, as the composer submits it: which município and
+ * what tags the stop generates. The title is deliberately NOT part of it: it
+ * is composed on the server from the município's own name, so a client cannot
+ * label a draft after a município it did not pick.
*/
-const tourStopDraftSchema = activityFieldsSchema
- .pick({ municipality: true, kind: true })
- .extend({ origin: z.enum(activityOrigins) })
+const tourStopDraftSchema = activityFieldsSchema.pick({ municipality: true, tags: true })
export const tourStopDraftsSchema = z.array(tourStopDraftSchema)
diff --git a/src/migrations/20260806_120000_c14_remodel_activity_agenda.ts b/src/migrations/20260806_120000_c14_remodel_activity_agenda.ts
new file mode 100644
index 00000000..06bb3b7a
--- /dev/null
+++ b/src/migrations/20260806_120000_c14_remodel_activity_agenda.ts
@@ -0,0 +1,165 @@
+/**
+ * C14 — Remodel activity for the agenda (calendar) paradigm.
+ *
+ * Schema changes:
+ * - `kind` (enum select) → `tags` (text[] — free-form, hasMany text)
+ * - `origin` (enum select) → dropped
+ * - `deadline` (timestamp) → dropped
+ * - `status` enum: remove `rascunho`/`planejado`, keep `confirmado`/`realizado`/`cancelado`
+ *
+ * Data migration:
+ * - `kind` values are mapped to human-readable labels as tags (comício → Comício, etc.)
+ * - `rascunho` → `cancelado` (drafts had no date, honest destination)
+ * - `planejado` → `confirmado` (planned activities had dates, they stay as commitments)
+ * - Default status becomes `confirmado`
+ */
+import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
+
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ -- 1. Add tags column (text[])
+ ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "tags" text[];
+
+ -- 2. Migrate kind → tags (map enum values to human-readable labels)
+ UPDATE "activity"
+ SET "tags" = ARRAY[
+ CASE "kind"
+ WHEN 'caminhada' THEN 'Caminhada'
+ WHEN 'comicio' THEN 'Comício'
+ WHEN 'carreata' THEN 'Carreata'
+ WHEN 'panfletagem' THEN 'Panfletagem'
+ WHEN 'porta_a_porta' THEN 'Porta a porta'
+ WHEN 'reuniao_apoio' THEN 'Reunião de apoio'
+ WHEN 'lancamento' THEN 'Lançamento'
+ WHEN 'convencao' THEN 'Convenção'
+ WHEN 'ato' THEN 'Ato'
+ WHEN 'entrevista' THEN 'Entrevista'
+ WHEN 'producao_conteudo' THEN 'Produção de conteúdo'
+ WHEN 'digital' THEN 'Digital'
+ WHEN 'outro' THEN 'Outro'
+ ELSE 'Outro'
+ END
+ ]
+ WHERE "tags" IS NULL;
+
+ -- 3. Remap statuses: rascunho → cancelado, planejado → confirmado
+ UPDATE "activity" SET "status" = 'cancelado' WHERE "status" = 'rascunho';
+ UPDATE "activity" SET "status" = 'confirmado' WHERE "status" = 'planejado';
+
+ -- 4. Drop the old default (before changing enum type)
+ ALTER TABLE "activity" ALTER COLUMN "status" DROP DEFAULT;
+
+ -- 5. Drop old columns
+ ALTER TABLE "activity" DROP COLUMN IF EXISTS "kind";
+ ALTER TABLE "activity" DROP COLUMN IF EXISTS "origin";
+ ALTER TABLE "activity" DROP COLUMN IF EXISTS "deadline";
+
+ -- 6. Drop old indexes on kind
+ DROP INDEX IF EXISTS "activity_kind_idx";
+
+ -- 7. Add tags index (GIN for array containment queries)
+ CREATE INDEX IF NOT EXISTS "activity_tags_idx" ON "activity" USING gin ("tags");
+
+ -- 8. Drop old enum types
+ DROP TYPE IF EXISTS "public"."enum_activity_kind";
+ DROP TYPE IF EXISTS "public"."enum_activity_origin";
+
+ -- 9. Recreate status enum without rascunho/planejado
+ -- Drop partial index that references the old enum type
+ DROP INDEX IF EXISTS "activity_upcoming_start_at_idx";
+
+ -- Convert column to text first to detach from old enum
+ ALTER TABLE "activity"
+ ALTER COLUMN "status" TYPE text
+ USING "status"::text;
+
+ -- Drop the old enum type
+ DROP TYPE "public"."enum_activity_status";
+
+ -- Create the new enum
+ CREATE TYPE "public"."enum_activity_status" AS ENUM ('confirmado', 'realizado', 'cancelado');
+
+ -- Convert column back to the new enum type
+ ALTER TABLE "activity"
+ ALTER COLUMN "status" TYPE "public"."enum_activity_status"
+ USING "status"::text::"public"."enum_activity_status";
+
+ -- Set the new default
+ ALTER TABLE "activity" ALTER COLUMN "status" SET DEFAULT 'confirmado';
+
+ -- Recreate the partial index with the new enum type
+ CREATE INDEX IF NOT EXISTS "activity_upcoming_start_at_idx" ON "activity"
+ USING btree ("start_at")
+ WHERE "status" = ANY (ARRAY['confirmado']::"public"."enum_activity_status"[]);
+
+ -- Payload text search table (was action_plan_texts, never renamed in the
+ -- C13 rename migration). Payload 3.82.0 expects activity_texts.
+ CREATE TABLE IF NOT EXISTS "activity_texts" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "order" integer NOT NULL,
+ "parent_id" integer NOT NULL,
+ "path" varchar NOT NULL,
+ "text" varchar
+ );
+ CREATE INDEX IF NOT EXISTS "activity_texts_order_parent" ON "activity_texts" USING btree ("order", "parent_id");
+ ALTER TABLE "activity_texts" ADD CONSTRAINT "activity_texts_parent_fk"
+ FOREIGN KEY ("parent_id") REFERENCES "activity"("id") ON DELETE cascade ON UPDATE no action;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ // The down migration restores the schema but cannot recover dropped data.
+ // origin/deadline values are lost; kind is restored from tags[0] reverse-mapped.
+ await db.execute(sql`
+ -- Restore old enum types
+ CREATE TYPE "public"."enum_activity_kind" AS ENUM ('caminhada', 'comicio', 'carreata', 'panfletagem', 'porta_a_porta', 'reuniao_apoio', 'lancamento', 'convencao', 'ato', 'entrevista', 'producao_conteudo', 'digital', 'outro');
+ CREATE TYPE "public"."enum_activity_origin" AS ENUM ('dado', 'pedido_broker', 'obrigacao_politica');
+
+ -- Replace status enum with the old one
+ ALTER TYPE "public"."enum_activity_status" RENAME TO "enum_activity_status_new";
+ CREATE TYPE "public"."enum_activity_status" AS ENUM ('rascunho', 'planejado', 'confirmado', 'realizado', 'cancelado');
+ ALTER TABLE "activity"
+ ALTER COLUMN "status" TYPE "public"."enum_activity_status"
+ USING "status"::text::"public"."enum_activity_status";
+ DROP TYPE IF EXISTS "public"."enum_activity_status_new";
+
+ -- Restore status default
+ ALTER TABLE "activity" ALTER COLUMN "status" SET DEFAULT 'rascunho';
+
+ -- Add columns back
+ ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "kind" "public"."enum_activity_kind";
+ ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "origin" "public"."enum_activity_origin" DEFAULT 'dado';
+ ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "deadline" timestamp(3) with time zone;
+
+ -- Reverse-map tags[0] → kind (best effort)
+ UPDATE "activity"
+ SET "kind" = CASE "tags"[1]
+ WHEN 'Caminhada' THEN 'caminhada'
+ WHEN 'Comício' THEN 'comicio'
+ WHEN 'Carreata' THEN 'carreata'
+ WHEN 'Panfletagem' THEN 'panfletagem'
+ WHEN 'Porta a porta' THEN 'porta_a_porta'
+ WHEN 'Reunião de apoio' THEN 'reuniao_apoio'
+ WHEN 'Lançamento' THEN 'lancamento'
+ WHEN 'Convenção' THEN 'convencao'
+ WHEN 'Ato' THEN 'ato'
+ WHEN 'Entrevista' THEN 'entrevista'
+ WHEN 'Produção de conteúdo' THEN 'producao_conteudo'
+ WHEN 'Digital' THEN 'digital'
+ ELSE 'outro'
+ END
+ WHERE "tags" IS NOT NULL AND array_length("tags", 1) > 0;
+
+ ALTER TABLE "activity" ALTER COLUMN "kind" SET NOT NULL;
+
+ -- Restore kind index
+ CREATE INDEX IF NOT EXISTS "activity_kind_idx" ON "activity" USING btree ("kind");
+
+ -- Drop tags column and index
+ DROP INDEX IF EXISTS "activity_tags_idx";
+ ALTER TABLE "activity" DROP COLUMN IF EXISTS "tags";
+
+ -- Drop text search table
+ DROP TABLE IF EXISTS "activity_texts";
+ `)
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index 8c88f91c..671d8f7b 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -40,6 +40,7 @@ import * as migration_20260731_014319_add_campaign_notifications from './2026073
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
+import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
export const migrations = [
{
@@ -252,4 +253,9 @@ export const migrations = [
down: migration_20260804_061017_add_state_deputy_advisors.down,
name: '20260804_061017_add_state_deputy_advisors'
},
+ {
+ up: migration_20260806_120000_c14_remodel_activity_agenda.up,
+ down: migration_20260806_120000_c14_remodel_activity_agenda.down,
+ name: '20260806_120000_c14_remodel_activity_agenda'
+ },
];
diff --git a/src/payload-types.ts b/src/payload-types.ts
index cf454150..a4a0c756 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -730,30 +730,18 @@ export interface Activity {
id: number;
title: string;
slug: string;
- kind:
- | 'caminhada'
- | 'comicio'
- | 'carreata'
- | 'panfletagem'
- | 'porta_a_porta'
- | 'reuniao_apoio'
- | 'lancamento'
- | 'convencao'
- | 'ato'
- | 'entrevista'
- | 'producao_conteudo'
- | 'digital'
- | 'outro';
- status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
+ /**
+ * Classificação livre do compromisso (ex.: comício, imprensa, reunião).
+ */
+ tags?: string[] | null;
+ status: 'confirmado' | 'realizado' | 'cancelado';
description?: string | null;
- origin?: ('dado' | 'pedido_broker' | 'obrigacao_politica') | null;
/**
* Marque quando o deputado Jorge Solla estiver presente na atividade.
*/
deputyPresent?: boolean | null;
startAt?: string | null;
endAt?: string | null;
- deadline?: string | null;
municipality: number | Municipality;
locality?: string | null;
organizations?: (number | Organization)[] | null;
@@ -1763,14 +1751,12 @@ export interface MunicipalityUpdateSelect {
export interface ActivitySelect {
title?: T;
slug?: T;
- kind?: T;
+ tags?: T;
status?: T;
description?: T;
- origin?: T;
deputyPresent?: T;
startAt?: T;
endAt?: T;
- deadline?: T;
municipality?: T;
locality?: T;
organizations?: T;
diff --git a/src/utilities/activityFormData.ts b/src/utilities/activityFormData.ts
index 242d4f31..154d6243 100644
--- a/src/utilities/activityFormData.ts
+++ b/src/utilities/activityFormData.ts
@@ -13,10 +13,10 @@ import {
} from '@/lib/formData'
import {
activityDemandDraftsSchema,
- activityKinds,
- activityOrigins,
activityStatuses,
MAX_ACTIVITY_DEMAND_DRAFTS,
+ MAX_ACTIVITY_TAG_LENGTH,
+ MAX_ACTIVITY_TAGS,
tourStopDraftsSchema,
type ActivityCreateInput,
type ActivityDemandDraft,
@@ -36,7 +36,7 @@ const parseDateTimeFormField = (formData: FormData, field: string): string | und
const value = optionalFormText(formData, field)
if (!value) return undefined
const iso = parseBahiaDateTimeInput(value)
- if (!iso) throw new FormDataBoundaryError(field, 'Data e horário inválidos.')
+ if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.')
return iso
}
@@ -100,20 +100,32 @@ const parseDemandDraftsFormData = (formData: FormData): ActivityDemandDraft[] =>
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
}
+const parseTagsFormData = (formData: FormData): string[] => {
+ const raw = boundedJsonFormValue(formData, 'tagsJson', 4_000)
+ if (raw === undefined) return []
+ if (!Array.isArray(raw)) {
+ throw new FormDataBoundaryError('tagsJson', 'Lista de tags inválida.')
+ }
+ const tags = raw
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
+ .filter((tag) => tag.length > 0)
+ .map((tag) => tag.slice(0, MAX_ACTIVITY_TAG_LENGTH))
+ .slice(0, MAX_ACTIVITY_TAGS)
+ return tags
+}
+
const parseSharedActivityFormData = (formData: FormData) => {
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
return {
title: optionalFormText(formData, 'title') ?? '',
- kind: optionalFormText(formData, 'kind') as (typeof activityKinds)[number],
- origin: optionalFormText(formData, 'origin') as (typeof activityOrigins)[number] | undefined,
+ tags: parseTagsFormData(formData),
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
description: optionalFormText(formData, 'description'),
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
startAt: parseDateTimeFormField(formData, 'startAt'),
endAt: parseDateTimeFormField(formData, 'endAt'),
- deadline: parseDateTimeFormField(formData, 'deadline'),
municipality: requiredRelationshipFormValue(formData, 'municipality'),
locality: optionalFormText(formData, 'locality'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
@@ -134,7 +146,7 @@ export const parseActivityCreateFormData = (formData: FormData): ParsedActivityC
return {
...shared,
- status: (shared.status ?? 'rascunho') as ActivityCreateInput['status'],
+ status: (shared.status ?? 'confirmado') as ActivityCreateInput['status'],
responsible: shared.responsible ?? undefined,
leadership: shared.leadership ?? undefined,
organizations: shared.organizations.length ? shared.organizations : undefined,
@@ -195,7 +207,6 @@ export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityU
description: shared.description ?? null,
startAt: shared.startAt ?? null,
endAt: shared.endAt ?? null,
- deadline: shared.deadline ?? null,
responsible: shared.responsible,
leadership: shared.leadership,
organizations: shared.organizations,
diff --git a/src/utilities/activityOmnibox.ts b/src/utilities/activityOmnibox.ts
index daaa1598..0e7fcd86 100644
--- a/src/utilities/activityOmnibox.ts
+++ b/src/utilities/activityOmnibox.ts
@@ -9,7 +9,7 @@ import {
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
-import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
+import { activityStatusLabels } from '@/lib/schemas/activity'
import {
activityTabLabels,
activityTabs,
@@ -38,14 +38,14 @@ const parseStateFromParams = (
const setExclusiveField = (
state: ActivityListState,
- field: 'kind' | 'status' | 'municipality',
+ field: 'tag' | 'status' | 'municipality',
value: string | undefined,
): ActivityListState => {
const params = buildActivityListSearchParams(withPageReset(state))
const raw: Record = Object.fromEntries(params.entries())
- if (field === 'kind') {
- raw.kind = value
+ if (field === 'tag') {
+ raw.tag = value
} else if (field === 'status') {
raw.status = value
} else {
@@ -59,7 +59,6 @@ const tabKeywords: Record = {
proximos: ['proximos', 'próximos', 'proximo', 'próximo'],
todos: ['todos', 'todas'],
realizados: ['realizados', 'realizado'],
- rascunhos: ['rascunhos', 'rascunho'],
}
const setTab = (state: ActivityListState, tab: ActivityTab): ActivityListState => {
@@ -91,10 +90,10 @@ export const buildActivityOmniboxChips = ({
})
}
- if (state.kind) {
+ if (state.tag) {
chips.push({
- id: `kind:${state.kind}`,
- label: chipLabel('Tipo', activityKindLabels[state.kind]),
+ id: `tag:${state.tag}`,
+ label: chipLabel('Tag', state.tag),
})
}
@@ -121,9 +120,11 @@ export const buildActivityOmniboxChips = ({
export const buildActivityOmniboxSuggestionSeeds = ({
tab,
municipalityOptions,
+ knownTags,
}: {
tab: ActivityTab
municipalityOptions: readonly ActivityFilterOption[]
+ knownTags?: readonly string[]
}) => {
const seeds = []
@@ -141,14 +142,14 @@ export const buildActivityOmniboxSuggestionSeeds = ({
)
}
- for (const [value, label] of Object.entries(activityKindLabels)) {
+ for (const tag of knownTags ?? []) {
seeds.push(
createOmniboxSuggestionSeed(
{
- id: `kind:${value}`,
- group: 'Tipo',
- label,
- keywords: ['tipo', 'atividade'],
+ id: `tag:${tag}`,
+ group: 'Tag',
+ label: tag,
+ keywords: ['tag', 'tipo'],
},
{ emptyQueryVisible: true },
),
@@ -210,12 +211,12 @@ export const applyActivityOmniboxSuggestion = ({
return { kind: 'url', state: next }
}
- if (suggestionId.startsWith('kind:')) {
- const value = suggestionId.slice(5)
+ if (suggestionId.startsWith('tag:')) {
+ const value = suggestionId.slice(4)
const next =
- state.kind === value
- ? setExclusiveField(state, 'kind', undefined)
- : setExclusiveField(state, 'kind', value)
+ state.tag === value
+ ? setExclusiveField(state, 'tag', undefined)
+ : setExclusiveField(state, 'tag', value)
return { kind: 'url', state: next }
}
@@ -253,8 +254,8 @@ export const removeActivityOmniboxChip = ({
return { kind: 'url', state: setTab(state, 'proximos') }
}
- if (chipId.startsWith('kind:')) {
- return { kind: 'url', state: setExclusiveField(state, 'kind', undefined) }
+ if (chipId.startsWith('tag:')) {
+ return { kind: 'url', state: setExclusiveField(state, 'tag', undefined) }
}
if (chipId.startsWith('status:')) {
diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts
index a42236fa..178c1311 100644
--- a/src/utilities/activityUi.ts
+++ b/src/utilities/activityUi.ts
@@ -1,7 +1,7 @@
import type { Where } from 'payload'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
-import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
+import { activityStatuses, type ActivityStatus } from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
@@ -13,39 +13,35 @@ import {
export const activityPageSize = 25
-export const activityTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
+export const activityTabs = ['proximos', 'todos', 'realizados'] as const
export type ActivityTab = (typeof activityTabs)[number]
-export type ActivityStatus = (typeof activityStatuses)[number]
+export { type ActivityStatus }
export const activityTabLabels: Record = {
proximos: 'Próximos',
todos: 'Todos',
realizados: 'Realizados',
- rascunhos: 'Rascunhos',
}
export type ActivityListState = {
page: number
tab: ActivityTab
q?: string
- kind?: ActivityKind
+ tag?: string
status?: ActivityStatus
municipality?: number
}
type RawSearchParams = CampaignListRawSearchParams
-const activityListParamNames = ['q', 'tab', 'kind', 'status', 'municipality', 'page'] as const
+const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
const activityListParamNameSet = new Set(activityListParamNames)
const isActivityTab = (value: string | undefined): value is ActivityTab =>
activityTabs.includes(value as ActivityTab)
-const isActivityKind = (value: string | undefined): value is ActivityKind =>
- activityKinds.includes(value as ActivityKind)
-
const isActivityStatus = (value: string | undefined): value is ActivityStatus =>
activityStatuses.includes(value as ActivityStatus)
@@ -60,8 +56,8 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
const q = isActivityListSearchReady(normalizedText(firstValue(params.q)))
const rawTab = firstValue(params.tab)
const tab = isActivityTab(rawTab) ? rawTab : 'proximos'
- const rawKind = firstValue(params.kind)
- const kind = isActivityKind(rawKind) ? rawKind : undefined
+ const rawTag = firstValue(params.tag)
+ const tag = rawTag?.trim() ? rawTag.trim() : undefined
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
const status = isActivityStatus(rawStatus) ? rawStatus : undefined
const municipality = strictDecimalInteger(firstValue(params.municipality))
@@ -70,7 +66,7 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
page: rawPage ?? 1,
tab,
...(q ? { q } : {}),
- ...(kind ? { kind } : {}),
+ ...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
}
@@ -88,16 +84,14 @@ export const buildActivityListWhere = (state: ActivityListState, now: Date): Whe
}
}
- if (state.kind) filters.push({ kind: { equals: state.kind } })
+ if (state.tag) filters.push({ tags: { contains: state.tag } })
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
if (state.tab === 'proximos') {
- filters.push({ status: { in: ['planejado', 'confirmado'] } })
+ filters.push({ status: { equals: 'confirmado' } })
filters.push({ startAt: { greater_than_equal: now.toISOString() } })
} else if (state.tab === 'realizados') {
filters.push({ status: { equals: 'realizado' } })
- } else if (state.tab === 'rascunhos') {
- filters.push({ status: { equals: 'rascunho' } })
} else if (state.status) {
filters.push({ status: { equals: state.status } })
}
@@ -113,7 +107,7 @@ export const buildActivityListSearchParams = (
page: String(page),
q: state.q,
tab: state.tab,
- kind: state.kind,
+ tag: state.tag,
status: state.status,
municipality: state.municipality === undefined ? undefined : String(state.municipality),
})
@@ -121,7 +115,7 @@ export const buildActivityListSearchParams = (
if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
- if (canonicalState.kind) params.set('kind', canonicalState.kind)
+ if (canonicalState.tag) params.set('tag', canonicalState.tag)
if (canonicalState.status) params.set('status', canonicalState.status)
if (canonicalState.municipality) params.set('municipality', String(canonicalState.municipality))
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
diff --git a/src/utilities/activityViewModels.ts b/src/utilities/activityViewModels.ts
index 6cba5d5b..1056e63e 100644
--- a/src/utilities/activityViewModels.ts
+++ b/src/utilities/activityViewModels.ts
@@ -1,6 +1,5 @@
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
-import type { ActivityOrigin } from '@/lib/schemas/activity'
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
@@ -41,7 +40,7 @@ const formatActivityLocationLabel = ({
export const activityListSelect = {
title: true,
slug: true,
- kind: true,
+ tags: true,
status: true,
deputyPresent: true,
startAt: true,
@@ -57,7 +56,7 @@ export type ActivityListViewModel = {
id: number
title: string
slug: string
- kind: Activity['kind']
+ tags: string[]
status: Activity['status']
deputyPresent: boolean
startAt: string | null
@@ -80,7 +79,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
id: activity.id,
title: activity.title,
slug: activity.slug,
- kind: activity.kind,
+ tags: activity.tags ?? [],
status: activity.status,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
@@ -99,14 +98,12 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
export const activityFormSelect = {
title: true,
slug: true,
- kind: true,
- origin: true,
+ tags: true,
status: true,
description: true,
deputyPresent: true,
startAt: true,
endAt: true,
- deadline: true,
municipality: true,
locality: true,
organizations: true,
@@ -128,14 +125,12 @@ export type ActivityFormViewModel = {
id: number
title: string
slug: string
- kind: Activity['kind']
- origin: ActivityOrigin
+ tags: string[]
status: Activity['status']
description: string | null
deputyPresent: boolean
startAt: string | null
endAt: string | null
- deadline: string | null
municipalityId: number | null
locality: string | null
organizationIDs: number[]
@@ -152,14 +147,12 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
id: activity.id,
title: activity.title,
slug: activity.slug,
- kind: activity.kind,
- origin: activity.origin ?? 'dado',
+ tags: activity.tags ?? [],
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
- deadline: activity.deadline ?? null,
municipalityId: relationshipId(activity.municipality),
locality: activity.locality ?? null,
organizationIDs: relationshipIds(activity.organizations),
@@ -201,14 +194,12 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
const activityDetailContextSelect = {
title: true,
slug: true,
- kind: true,
- origin: true,
+ tags: true,
status: true,
description: true,
deputyPresent: true,
startAt: true,
endAt: true,
- deadline: true,
municipality: true,
locality: true,
organizations: true,
@@ -269,14 +260,12 @@ export type ActivityDetailViewModel = {
id: number
title: string
slug: string
- kind: Activity['kind']
- origin: ActivityOrigin
+ tags: string[]
status: Activity['status']
description: string | null
deputyPresent: boolean
startAt: string | null
endAt: string | null
- deadline: string | null
municipality: ActivityMunicipalitySummary | null
locality: string | null
locationLabel: string
@@ -340,14 +329,12 @@ export const toActivityDetailViewModel = (
id: activity.id,
title: activity.title,
slug: activity.slug,
- kind: activity.kind,
- origin: activity.origin ?? 'dado',
+ tags: activity.tags ?? [],
status: activity.status,
description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent),
startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null,
- deadline: activity.deadline ?? null,
municipality,
locality: activity.locality ?? null,
locationLabel: formatActivityLocationLabel({
diff --git a/src/utilities/ai/campaignNavigationUrls.ts b/src/utilities/ai/campaignNavigationUrls.ts
index 0d1d7185..839ecb5e 100644
--- a/src/utilities/ai/campaignNavigationUrls.ts
+++ b/src/utilities/ai/campaignNavigationUrls.ts
@@ -10,7 +10,7 @@ import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
-import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
+import { activityStatuses } from '@/lib/schemas/activity'
import {
campaignDemandKinds,
campaignDemandStatuses,
@@ -166,7 +166,7 @@ export type CampaignNavigationLinkRequest =
destination: 'activityList'
tab?: ActivityTab
q?: string
- kind?: ActivityKind
+ tag?: string
status?: (typeof activityStatuses)[number]
municipality?: number
})
@@ -481,7 +481,7 @@ const buildPathForRequest = (
}
case 'activityList': {
const tab = request.tab && activityTabs.includes(request.tab) ? request.tab : undefined
- const kind = request.kind && activityKinds.includes(request.kind) ? request.kind : undefined
+ const tag = request.tag?.trim() ? request.tag.trim() : undefined
const status =
request.status && activityStatuses.includes(request.status) ? request.status : undefined
const municipality =
@@ -493,7 +493,7 @@ const buildPathForRequest = (
page: 1,
tab: tab ?? 'proximos',
...(request.q?.trim() ? { q: request.q.trim() } : {}),
- ...(kind ? { kind } : {}),
+ ...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
},
diff --git a/src/utilities/ai/tools/buildCampaignLinks.ts b/src/utilities/ai/tools/buildCampaignLinks.ts
index d40d768b..f17d64bf 100644
--- a/src/utilities/ai/tools/buildCampaignLinks.ts
+++ b/src/utilities/ai/tools/buildCampaignLinks.ts
@@ -7,7 +7,7 @@ import {
campaignIntelligenceConcepts,
type CampaignConceptId,
} from '@/lib/campaignIntelligenceConcepts'
-import { activityKinds, activityStatuses } from '@/lib/schemas/activity'
+import { activityStatuses } from '@/lib/schemas/activity'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { organizationKinds } from '@/lib/schemas/organization'
@@ -156,7 +156,7 @@ const linkRequestSchema = z.discriminatedUnion('destination', [
destination: z.literal('activityList'),
tab: z.enum(activityTabs).optional(),
q: z.string().optional(),
- kind: z.enum(activityKinds).optional(),
+ tag: z.string().trim().min(1).optional(),
status: z.enum(activityStatuses).optional(),
municipality: z.number().int().positive().optional(),
label: labelSchema,
diff --git a/src/utilities/municipality/municipalityTriggers.ts b/src/utilities/municipality/municipalityTriggers.ts
index 0ed1c3cf..2ffa4f6b 100644
--- a/src/utilities/municipality/municipalityTriggers.ts
+++ b/src/utilities/municipality/municipalityTriggers.ts
@@ -112,7 +112,7 @@ const ADVERSARY_SIGNAL_TYPES: MunicipalitySignalType[] = [
]
/** Agenda legs: drafts are intentions and cancellations never happened. */
-const AGENDA_ACTIVITY_STATUSES = ['planejado', 'confirmado', 'realizado'] as const
+const AGENDA_ACTIVITY_STATUSES = ['confirmado', 'realizado'] as const
const isoDaysAgo = (now: Date, days: number): string =>
new Date(now.getTime() - days * DAY_MS).toISOString()
diff --git a/src/utilities/visit/visitPlannerViews.ts b/src/utilities/visit/visitPlannerViews.ts
index 2f9bd9bc..c71fcd76 100644
--- a/src/utilities/visit/visitPlannerViews.ts
+++ b/src/utilities/visit/visitPlannerViews.ts
@@ -1,5 +1,5 @@
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
-import type { ActivityKind } from '@/lib/schemas/activity'
+// C14: ActivityKind removed; stops carry free-form tags now.
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage'
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
@@ -151,10 +151,14 @@ export const resolveTourStopRole = (
* marker, so every stop keeps the real kind of what happens there — an act at
* the anchor, a support meeting at a stop that is being opened.
*/
-export const tourStopRoleActivityKind: Record = {
- ancora: 'comicio',
- satelite: 'reuniao_apoio',
- semente: 'reuniao_apoio',
+/**
+ * C14 — the activity tags each role generates. `deputyPresent` is still the
+ * candidate-presence marker on the resulting activity.
+ */
+export const tourStopRoleActivityTags: Record = {
+ ancora: ['Comício'],
+ satelite: ['Reunião de apoio'],
+ semente: ['Reunião de apoio'],
}
/**
diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts
index d7cef40d..c8a1ea0d 100644
--- a/tests/e2e/campaignActivity.e2e.spec.ts
+++ b/tests/e2e/campaignActivity.e2e.spec.ts
@@ -3,7 +3,7 @@ import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures
test.describe('Atividades — registro-fundação', () => {
test.setTimeout(90_000)
- test('registra origem, cria demandas vinculadas e exibe a explicação dos sinais', async ({
+ test('cria compromisso com demandas vinculadas e exibe os sinais no município', async ({
campaign,
page,
}) => {
@@ -21,8 +21,7 @@ test.describe('Atividades — registro-fundação', () => {
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
await page.getByLabel('Título *').fill(activityTitle)
- await page.getByLabel('Tipo de atividade *').selectOption('caminhada')
- await page.getByLabel('Origem da atividade').selectOption('obrigacao_politica')
+ await page.getByLabel('Início *').fill('2026-08-15T10:00')
await page.getByLabel('Município *').selectOption(String(municipality.id))
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
@@ -37,7 +36,6 @@ test.describe('Atividades — registro-fundação', () => {
await expect(campaignPageChrome(page, activityTitle)).toBeVisible({
timeout: 15_000,
})
- await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
diff --git a/tests/int/campaignActivity.int.spec.ts b/tests/int/campaignActivity.int.spec.ts
index f26f97f4..38e72fee 100644
--- a/tests/int/campaignActivity.int.spec.ts
+++ b/tests/int/campaignActivity.int.spec.ts
@@ -38,8 +38,8 @@ const campaignFixtures = installCampaignFixtures({
const validActivityInput = (municipalityId: number) => ({
title: campaignFixtures().value('Caminhada Centro'),
- kind: 'caminhada' as const,
- status: 'planejado' as const,
+ tags: ['Caminhada'],
+ status: 'confirmado' as const,
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: municipalityId,
locality: 'Centro',
@@ -50,23 +50,16 @@ describe('activity domain', () => {
payload = await getPayload({ config: await config })
})
- it('requires startAt outside rascunho and allows drafts without it', () => {
+ it('requires startAt on create and update', () => {
+ // Create schema only accepts 'confirmado' and always requires startAt.
expect(
activityCreateSchema.safeParse({
...validActivityInput(1),
- status: 'rascunho',
- startAt: undefined,
- }).success,
- ).toBe(true)
-
- expect(
- activityCreateSchema.safeParse({
- ...validActivityInput(1),
- status: 'planejado',
startAt: undefined,
}).success,
).toBe(false)
+ // Update schema also requires startAt.
expect(
activityUpdateSchema.safeParse({
id: 1,
@@ -79,38 +72,16 @@ describe('activity domain', () => {
it('requires a municipality on create', () => {
const result = activityCreateSchema.safeParse({
title: campaignFixtures().value('Atividade sem município'),
- kind: 'caminhada',
- status: 'rascunho',
+ tags: ['Caminhada'],
+ status: 'confirmado',
})
expect(result.success).toBe(false)
})
- it('keeps the recorded origin of a new activity', () => {
- const parsed = activityCreateSchema.parse({
- ...validActivityInput(1),
- origin: 'pedido_broker',
- })
-
- expect(parsed.origin).toBe('pedido_broker')
- })
-
- it('parses the origin selected in the activity form', () => {
- const formData = new FormData()
- formData.set('title', 'Caminhada no centro')
- formData.set('kind', 'caminhada')
- formData.set('status', 'rascunho')
- formData.set('municipality', '1')
- formData.set('origin', 'obrigacao_politica')
- formData.set('tasksJson', '[]')
-
- expect(parseActivityCreateFormData(formData).origin).toBe('obrigacao_politica')
- })
-
it('parses several demand drafts from the activity form', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
- formData.set('kind', 'caminhada')
- formData.set('status', 'rascunho')
+ formData.set('status', 'cancelado')
formData.set('municipality', '1')
formData.set('tasksJson', '[]')
formData.set(
@@ -138,7 +109,6 @@ describe('activity domain', () => {
{
...validActivityInput(municipality.id),
title: fixtures.value('Atividade com demandas'),
- origin: 'dado',
},
[
{ title: fixtures.value('Panfletos'), kind: 'material' },
@@ -359,12 +329,12 @@ describe('activity domain', () => {
data: stub({
...validActivityInput(municipality.id),
title: fixtures.value('Atividade sem data'),
- startAt: null,
+ startAt: undefined,
createdBy: coordinator.id,
}),
overrideAccess: true,
}),
- ).rejects.toThrow('Informe a data e horário de início ao planejar ou confirmar a atividade.')
+ ).rejects.toThrow('Informe a data e horário de início do compromisso.')
const start = new Date(Date.now() + 86_400_000)
await expect(
@@ -440,8 +410,8 @@ describe('tour composer (E13)', () => {
parseTourDraftFormData(
tourFormData(
[
- { municipality: 7, kind: 'comicio', origin: 'dado' },
- { municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
+ { municipality: 7, tags: ['Comício'] },
+ { municipality: 9, tags: ['Reunião de Apoio'] },
],
{ note: 'Falar com o vereador antes.' },
),
@@ -450,8 +420,8 @@ describe('tour composer (E13)', () => {
tourName: 'Giro Sisal 27/07',
note: 'Falar com o vereador antes.',
stops: [
- { municipality: 7, kind: 'comicio', origin: 'dado' },
- { municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
+ { municipality: 7, tags: ['Comício'] },
+ { municipality: 9, tags: ['Reunião de Apoio'] },
],
})
})
@@ -459,19 +429,11 @@ describe('tour composer (E13)', () => {
it('refuses a composition it cannot trust', () => {
expect(() => parseTourDraftFormData(tourFormData([]))).toThrow(/ao menos uma parada/i)
expect(() =>
- parseTourDraftFormData(tourFormData([{ municipality: 7, kind: 'visita', origin: 'dado' }])),
- ).toThrow(/Paradas do giro inválidas/i)
- expect(() =>
- parseTourDraftFormData(tourFormData([{ municipality: 0, kind: 'comicio', origin: 'dado' }])),
+ parseTourDraftFormData(tourFormData([{ municipality: 0, tags: ['Comício'] }])),
).toThrow(/Paradas do giro inválidas/i)
expect(() =>
parseTourDraftFormData(
- tourFormData([{ municipality: 7, kind: 'comicio', origin: 'inventada' }]),
- ),
- ).toThrow(/Paradas do giro inválidas/i)
- expect(() =>
- parseTourDraftFormData(
- tourFormData([{ municipality: 7, kind: 'comicio', origin: 'dado' }], {
+ tourFormData([{ municipality: 7, tags: ['Comício'] }], {
tourName: 'G'.repeat(120),
}),
),
@@ -491,8 +453,8 @@ describe('tour composer (E13)', () => {
tourName,
note: 'Falar com o vereador antes.',
stops: [
- { municipality: anchor.id, kind: 'comicio', origin: 'dado' },
- { municipality: satellite.id, kind: 'reuniao_apoio', origin: 'dado' },
+ { municipality: anchor.id, tags: ['Comício'] },
+ { municipality: satellite.id, tags: ['Reunião de Apoio'] },
],
})
created.forEach((activity) => fixtures.own('activity', activity.id))
@@ -509,7 +471,7 @@ describe('tour composer (E13)', () => {
).toBe(true)
// Drafts without a date, flagged as the candidate's own agenda: that flag is
// what makes a giro derivable without a `tour` entity.
- expect(created.every((activity) => activity.status === 'rascunho')).toBe(true)
+ expect(created.every((activity) => activity.status === 'confirmado')).toBe(true)
expect(created.every((activity) => activity.deputyPresent === true)).toBe(true)
expect(created.every((activity) => activity.startAt === null)).toBe(true)
})
@@ -529,8 +491,8 @@ describe('tour composer (E13)', () => {
tourName,
note: undefined,
stops: [
- { municipality: inPortfolio.id, kind: 'comicio', origin: 'dado' },
- { municipality: outOfPortfolio.id, kind: 'reuniao_apoio', origin: 'dado' },
+ { municipality: inPortfolio.id, tags: ['Comício'] },
+ { municipality: outOfPortfolio.id, tags: ['Reunião de Apoio'] },
],
}),
).rejects.toThrow(/fora do seu escopo|saiu do seu escopo/i)
@@ -557,8 +519,7 @@ describe('tour composer (E13)', () => {
note: undefined,
stops: Array.from({ length: 9 }, () => ({
municipality: municipality.id,
- kind: 'reuniao_apoio' as const,
- origin: 'dado' as const,
+ tags: ['Reunião de Apoio'] as const,
})),
}),
).rejects.toThrow(/no máximo 8 paradas/i)
diff --git a/tests/int/campaignSuggestions.int.spec.ts b/tests/int/campaignSuggestions.int.spec.ts
index a1736edf..ed2df4bd 100644
--- a/tests/int/campaignSuggestions.int.spec.ts
+++ b/tests/int/campaignSuggestions.int.spec.ts
@@ -166,7 +166,7 @@ describe('loadMunicipalitySuggestions (E11)', () => {
collection: 'activity',
data: stub({
title: fixtures.value('Caminhada Centro'),
- kind: 'caminhada',
+ tags: ['Caminhada'],
status: 'realizado',
startAt: new Date(Date.now() - 10 * DAY_MS).toISOString(),
municipality: municipality.id,
diff --git a/tests/int/homeSearchActivities.int.spec.ts b/tests/int/homeSearchActivities.int.spec.ts
index db3341df..3e0e67ef 100644
--- a/tests/int/homeSearchActivities.int.spec.ts
+++ b/tests/int/homeSearchActivities.int.spec.ts
@@ -20,8 +20,8 @@ const campaignFixtures = installCampaignFixtures({
const validActivityInput = (municipalityId: number, title: string) => ({
title,
- kind: 'caminhada' as const,
- status: 'planejado' as const,
+ tags: ['Caminhada'],
+ status: 'confirmado' as const,
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: municipalityId,
locality: 'Centro',
@@ -113,16 +113,25 @@ describe('searchHomeActivities (B51)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const title = fixtures.value('Rascunho sem data')
- const activity = await createActivityRecord(payload, coordinator, {
- title,
- kind: 'caminhada',
- status: 'rascunho',
- municipality: municipality.id,
- })
- fixtures.own('activity', activity.id)
+ // Intentionally omits startAt (tour draft bypass) — the hook allows this
+ // via isTourDraft context, but the Payload type requires it.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const activity = (await (payload.create as any)({
+ collection: 'activity',
+ data: {
+ title,
+ tags: ['Caminhada'],
+ municipality: municipality.id,
+ createdBy: coordinator.id,
+ },
+ overrideAccess: true,
+ context: { isTourDraft: true },
+ draft: false,
+ })) as { id: number }
+ fixtures.own('activity', activity.id as number)
const result = await searchHomeActivities(payload, coordinator, 'Rascunho')
- const hit = result.find((row) => row.id === activity.id)
+ const hit = result.find((row) => row.id === (activity.id as number))
expect(hit?.secondary).toContain('Data a definir')
expect(hit?.secondary).toContain(municipality.name)
})
diff --git a/tests/int/municipalityDossierData.int.spec.ts b/tests/int/municipalityDossierData.int.spec.ts
index 57133bd1..c90aee7c 100644
--- a/tests/int/municipalityDossierData.int.spec.ts
+++ b/tests/int/municipalityDossierData.int.spec.ts
@@ -66,8 +66,8 @@ describe('municipality dossier data (E16)', () => {
const upcomingActivity = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Caminhada dossiê'),
- kind: 'caminhada',
- status: 'planejado',
+ tags: ['Caminhada'],
+ status: 'confirmado',
startAt: new Date(Date.now() + 86_400_000).toISOString(),
municipality: municipality.id,
locality: 'Centro',
diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts
index e91cab15..7e2ce1db 100644
--- a/tests/unit/activityUi.unit.spec.ts
+++ b/tests/unit/activityUi.unit.spec.ts
@@ -33,9 +33,8 @@ describe('parseActivityListParams', () => {
expect(parseActivityListParams({ tab: 'todos', status: 'inexistente' }).status).toBeUndefined()
})
- it('validates kind and municipality', () => {
- expect(parseActivityListParams({ kind: 'caminhada' }).kind).toBe('caminhada')
- expect(parseActivityListParams({ kind: 'festa' }).kind).toBeUndefined()
+ it('validates tag and municipality', () => {
+ expect(parseActivityListParams({ tag: 'Caminhada' }).tag).toBe('Caminhada')
expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined()
})
@@ -48,23 +47,20 @@ describe('parseActivityListParams', () => {
})
describe('buildActivityListWhere (tab → where matrix)', () => {
- it('proximos = planejado/confirmado starting from now', () => {
+ it('proximos = confirmado starting from now', () => {
const where = buildActivityListWhere({ page: 1, tab: 'proximos' }, NOW)
expect(where).toEqual({
and: [
- { status: { in: ['planejado', 'confirmado'] } },
+ { status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
- it('realizados and rascunhos filter by their status', () => {
+ it('realizados filters by its status', () => {
expect(buildActivityListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
and: [{ status: { equals: 'realizado' } }],
})
- expect(buildActivityListWhere({ page: 1, tab: 'rascunhos' }, NOW)).toEqual({
- and: [{ status: { equals: 'rascunho' } }],
- })
})
it('todos is unfiltered unless a status is chosen', () => {
@@ -74,14 +70,14 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
})
})
- it('prepends kind and municipality filters on any tab', () => {
+ it('prepends tag and municipality filters on any tab', () => {
const where = buildActivityListWhere(
- { page: 1, tab: 'realizados', kind: 'comicio', municipality: 3 },
+ { page: 1, tab: 'realizados', tag: 'Comício', municipality: 3 },
NOW,
)
expect(where).toEqual({
and: [
- { kind: { equals: 'comicio' } },
+ { tags: { contains: 'Comício' } },
{ municipality: { equals: 3 } },
{ status: { equals: 'realizado' } },
],
@@ -95,7 +91,7 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
{
or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }],
},
- { status: { in: ['planejado', 'confirmado'] } },
+ { status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
@@ -115,12 +111,12 @@ describe('buildActivityListSearchParams', () => {
page: 1,
tab: 'todos',
q: 'comício',
- kind: 'caminhada',
+ tag: 'Caminhada',
status: 'cancelado',
municipality: 9,
})
expect(params.toString()).toBe(
- 'q=com%C3%ADcio&tab=todos&kind=caminhada&status=cancelado&municipality=9',
+ 'q=com%C3%ADcio&tab=todos&tag=Caminhada&status=cancelado&municipality=9',
)
})
})
@@ -134,8 +130,8 @@ describe('hrefs and canonical URL resolution', () => {
})
it('derives the filters key from the canonical serialization', () => {
- expect(buildActivityFiltersKey({ page: 1, tab: 'todos', kind: 'comicio' })).toBe(
- 'tab=todos&kind=comicio',
+ expect(buildActivityFiltersKey({ page: 1, tab: 'todos', tag: 'Comício' })).toBe(
+ 'tab=todos&tag=Com%C3%ADcio',
)
})
diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts
index 0290b361..9d2648e7 100644
--- a/tests/unit/listOmniboxB128.unit.spec.ts
+++ b/tests/unit/listOmniboxB128.unit.spec.ts
@@ -177,7 +177,7 @@ describe('list omnibox adapters (B128)', () => {
})
it('activity clears filters but keeps tab', () => {
- const state = parseActivityListParams({ tab: 'todos', kind: 'comicio', status: 'planejado' })
+ const state = parseActivityListParams({ tab: 'todos', tag: 'Comício', status: 'confirmado' })
const chips = buildActivityOmniboxChips({ state, municipalityLabelsById: new Map() })
expect(chips.length).toBeGreaterThan(0)
@@ -185,10 +185,10 @@ describe('list omnibox adapters (B128)', () => {
expect(cleared.kind).toBe('clear')
if (cleared.kind === 'clear') {
expect(cleared.state.tab).toBe('todos')
- expect(cleared.state.kind).toBeUndefined()
+ expect(cleared.state.tag).toBeUndefined()
}
- const applied = applyActivityOmniboxSuggestion({ state, suggestionId: 'kind:comicio' })
+ const applied = applyActivityOmniboxSuggestion({ state, suggestionId: 'tag:Comício' })
expect(applied.kind).toBe('url')
})
@@ -206,17 +206,17 @@ describe('list omnibox adapters (B128)', () => {
if (tabApplied.kind === 'url') expect(tabApplied.state.tab).toBe('realizados')
const searchApplied = applyActivityOmniboxSuggestion({
- state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
+ state: parseActivityListParams({ tab: 'todos', status: 'confirmado' }),
suggestionId: 'q:comício',
})
expect(searchApplied.kind).toBe('url')
if (searchApplied.kind === 'url') {
expect(searchApplied.state.q).toBe('comício')
- expect(searchApplied.state.status).toBe('planejado')
+ expect(searchApplied.state.status).toBe('confirmado')
}
const leavingTodos = applyActivityOmniboxSuggestion({
- state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
+ state: parseActivityListParams({ tab: 'todos', status: 'confirmado' }),
suggestionId: 'tab:realizados',
})
if (leavingTodos.kind === 'url') expect(leavingTodos.state.status).toBeUndefined()