feat(campaign): C14 remodel activity for agenda paradigm (#389)

- Replace kind enum with free-form tags (text[] hasMany)
- Remove origin and deadline fields
- Status: remove rascunho/planejado, keep confirmado/realizado/cancelado
- startAt required for user-facing creates (optional for tour drafts)
- Add deputyPresent time gate (only coordinator/candidate can reschedule)
- UI: calendar-like form with tag input, no origin/deadline, no draft status
- E13 giros: tourStopRoleActivityKind → tourStopRoleActivityTags
- Migration: kind→tags with label mapping, status remap, enum recreation
- Activity texts table creation (was missing from migration history)

Closes #389
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-06 05:20:05 -03:00
parent 2766eb7b0e
commit adc29c0379
30 changed files with 790 additions and 564 deletions

View file

@ -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<TourStopRole, string[]>` (`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`

View file

@ -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 = ({
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Origem da atividade</dt>
<dd>{activityOriginLabels[view.origin]}</dd>
<div className="sm:col-span-2">
<dt className="text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-1 pt-1">
{view.tags.length > 0
? view.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))
: '—'}
</dd>
</div>
</dl>
</CardContent>

View file

@ -20,7 +20,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { activityKindLabels } from '@/lib/schemas/activity'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
import {
getActivityDetailTabRedirect,
@ -109,7 +109,11 @@ export default async function ActivityDetailPage({
<div className="flex flex-wrap items-center gap-2">
<ActivityStatusBadge status={view.status} />
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
<Badge variant="secondary">{activityKindLabels[view.kind]}</Badge>
{view.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
<span className="text-muted-foreground">
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>

View file

@ -32,7 +32,7 @@ export const createTourDraftsFormAction = async (
): Promise<CampaignFormActionState> =>
runCampaignRedirectFormAction({
execute: async () => createTourDraftActivities(parseTourDraftFormData(formData)),
redirectTo: () => buildActivityListHref({ page: 1, tab: 'rascunhos' }, 1),
redirectTo: () => buildActivityListHref({ page: 1, tab: 'todos' }, 1),
safeMessages: TOUR_SAFE_MESSAGES,
genericMessage:
'Não foi possível gerar os rascunhos do giro. Verifique se algum título já existe e tente novamente.',

View file

@ -24,7 +24,7 @@ import { buildTourComposerHref, parseTourComposerParams } from '@/utilities/visi
import {
composeTourSuggestion,
resolveTourStopRole,
tourStopRoleActivityKind,
tourStopRoleActivityTags,
tourSuggestionSlugs,
type VisitCandidateViewModel,
} from '@/utilities/visit/visitPlannerViews'
@ -52,7 +52,7 @@ const toStopOption = (
name: candidate.name,
role,
suggested,
kind: tourStopRoleActivityKind[role],
tags: tourStopRoleActivityTags[role],
metCount: candidate.eligibility.metCount,
conditionCount: candidate.eligibility.conditions.length,
unmetConditionLabels: candidate.eligibility.conditions

View file

@ -16,7 +16,7 @@ import {
type ActivityDemandDraft,
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import type { CampaignUser } from '@/payload-types'
import type { Activity, CampaignUser } from '@/payload-types'
import type { ParsedTourDraftFormData } from '@/utilities/activityFormData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
@ -40,7 +40,11 @@ export const createActivityRecord = async (
input: ActivityCreateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const data = activityCreateSchema.parse(input)
const raw = activityCreateSchema.parse(input)
// Payload date fields accept string | undefined, not null. Strip nulls.
const data = Object.fromEntries(
Object.entries(raw).filter(([, value]) => value !== null),
) as Record<string, unknown>
const demands = activityDemandDraftsSchema.parse(demandDrafts)
return withPayloadTransaction(
@ -61,7 +65,7 @@ export const createActivityRecord = async (
collection: 'campaignDemand',
data: hookFilledCreateData<'campaignDemand'>({
...demand,
municipality: data.municipality,
municipality: raw.municipality,
activity: activity.id,
}),
depth: 0,
@ -130,14 +134,18 @@ export const createTourDraftActivitiesRecord = async (
const created = []
for (const stop of stops) {
const data = activityCreateSchema.parse({
// Tour stops don't carry a startAt — the coordination sets it later.
// We bypass activityCreateSchema (which requires startAt) and build
// the create data directly; the beforeValidate hook allows this via
// the isTourDraft context flag.
const data = {
...stop,
title: `${tourName} — ${nameByID.get(stop.municipality)}`,
status: 'rascunho',
// The whole point of the planner: these drafts are the candidate's agenda.
status: 'confirmado' as const,
deputyPresent: true,
...(note ? { description: note } : {}),
})
tags: stop.tags ?? [],
}
created.push(
await payload.create({
@ -146,6 +154,7 @@ export const createTourDraftActivitiesRecord = async (
depth: 0,
user: currentActor,
overrideAccess: false,
context: { isTourDraft: true },
req,
}),
)
@ -163,7 +172,18 @@ const updateActivityRecord = async (
input: ActivityUpdateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const { id, ...data } = activityUpdateSchema.parse(input)
const { id, ...rawData } = activityUpdateSchema.parse(input)
// Payload date fields accept string | undefined, not null.
// Strip nulls only for required-ish fields; keep null for optional
// fields (endAt, responsible, leadership) so clearing them works.
const data = {
...Object.fromEntries(
Object.entries(rawData).filter(([key, value]) => {
if (value === null && (key === 'startAt' || key === 'status')) return false
return true
}),
),
} as Record<string, unknown>
const demands = activityDemandDraftsSchema.parse(demandDrafts)
return withPayloadTransaction(
@ -171,7 +191,7 @@ const updateActivityRecord = async (
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
const activity = await payload.update({
const activity = (await payload.update({
collection: 'activity',
id,
data,
@ -179,7 +199,7 @@ const updateActivityRecord = async (
user: currentActor,
overrideAccess: false,
req,
})
})) as unknown as Activity
const municipality =
typeof activity.municipality === 'number' ? activity.municipality : activity.municipality.id

View file

@ -6,24 +6,16 @@ import type {
import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import {
activityKindLabels,
activityKinds,
activityOriginLabels,
activityOrigins,
activityStatusLabels,
activityStatuses,
} from '@/lib/schemas/activity'
import { activityStatusLabels, activityStatuses } from '@/lib/schemas/activity'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import { isCampaignUnrestricted } from '@/utilities/access/shared'
import {
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
canManageActivityAdvisors,
canManageCampaignStaffField,
canReadActivity,
canReadCampaignStaffField,
canSetActivityStatus,
canSetActivitySystemField,
canUpdateActivity,
@ -34,33 +26,22 @@ import { systemStampedActorField } from '@/utilities/campaignAuditFields'
const isActivityMutationShortcut = (context: Record<string, unknown> | undefined) =>
context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate'
const ACTIVITY_KIND_OPTIONS = activityKinds.map((value) => ({
value,
label: activityKindLabels[value],
}))
const ACTIVITY_STATUS_OPTIONS = activityStatuses.map((value) => ({
value,
label: activityStatusLabels[value],
}))
const ACTIVITY_ORIGIN_OPTIONS = activityOrigins.map((value) => ({
value,
label: activityOriginLabels[value],
}))
const relationshipIds = (value: unknown): number[] =>
(Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null)
const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
title: trimmedText(doc.title),
slug: doc.slug ?? null,
kind: doc.kind ?? null,
tags: Array.isArray(doc.tags) ? [...doc.tags].sort() : [],
status: doc.status ?? null,
description: trimmedText(doc.description),
startAt: doc.startAt ?? null,
endAt: doc.endAt ?? null,
deadline: doc.deadline ?? null,
municipality: relationshipId(doc.municipality),
locality: trimmedText(doc.locality),
deputyPresent: Boolean(doc.deputyPresent),
@ -70,7 +51,6 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
leadership: relationshipId(doc.leadership),
resultSummary: trimmedText(doc.resultSummary),
resultMedia: relationshipIds(doc.resultMedia),
origin: doc.origin ?? null,
})
const setCanonicalActivitySlug: CollectionBeforeValidateHook = ({
@ -102,17 +82,16 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({
}) => {
if (isActivityMutationShortcut(context)) return data
if (!data) return data
// Tour drafts are created without startAt; the coordination sets it later.
if (context?.isTourDraft) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const status = typeof nextData.status === 'string' ? nextData.status : 'rascunho'
const startAt = nextData.startAt ?? null
const endAt = nextData.endAt ?? null
if (status !== 'rascunho' && !startAt) {
throw new APIError(
'Informe a data e horário de início ao planejar ou confirmar a atividade.',
400,
)
// C14: startAt is always required (no more rascunho without date).
if (!startAt) {
throw new APIError('Informe a data e horário de início do compromisso.', 400)
}
if (startAt && endAt) {
@ -156,6 +135,43 @@ const validateActivityAdvisors: CollectionBeforeValidateHook = async ({ data, re
return data
}
/**
* C14 — deputyPresent time gate: only coordinator and candidate can reschedule
* (change startAt/endAt) an activity where the deputy is present. Advisors can
* create/edit the rest of the activity but cannot move the time of a
* deputy-present commitment.
*/
const validateDeputyPresentTimeGate: CollectionBeforeValidateHook = async ({
data,
operation,
originalDoc,
req,
context,
}) => {
if (isActivityMutationShortcut(context)) return data
if (!data) return data
if (operation === 'create') return data
if (!originalDoc?.deputyPresent) return data
const timeChanged =
(data.startAt !== undefined && data.startAt !== (originalDoc.startAt ?? null)) ||
(data.endAt !== undefined && data.endAt !== (originalDoc.endAt ?? null))
if (!timeChanged) return data
const user = req.user
if (!user || user.collection !== 'campaignUser') return data
if (!isCampaignUnrestricted(user)) {
throw new APIError(
'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.',
403,
)
}
return data
}
const deriveActivityFields: CollectionBeforeChangeHook = ({
data,
operation,
@ -238,26 +254,26 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({
403,
)
}
}
if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
const previousTasks = originalDoc.tasks as Record<string, unknown>[]
if (data.tasks.length !== previousTasks.length) {
if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
const previousTasks = originalDoc.tasks as Record<string, unknown>[]
if (data.tasks.length !== previousTasks.length) {
throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
}
for (const [index, task] of data.tasks.entries()) {
const previousTask = previousTasks[index]
const taskRecord = task as Record<string, unknown>
if (
trimmedText(taskRecord.title) !== trimmedText(previousTask.title) ||
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
(taskRecord.due ?? null) !== (previousTask.due ?? null)
) {
throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403)
}
}
} else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) {
throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
}
for (const [index, task] of data.tasks.entries()) {
const previousTask = previousTasks[index]
const taskRecord = task as Record<string, unknown>
if (
trimmedText(taskRecord.title) !== trimmedText(previousTask.title) ||
relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) ||
(taskRecord.due ?? null) !== (previousTask.due ?? null)
) {
throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403)
}
}
} else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) {
throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403)
}
}
@ -273,7 +289,7 @@ export const Activity: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'title',
defaultColumns: ['title', 'kind', 'status', 'municipality', 'startAt', 'updatedAt'],
defaultColumns: ['title', 'tags', 'status', 'municipality', 'startAt', 'updatedAt'],
},
access: {
create: canCreateActivity,
@ -282,16 +298,16 @@ export const Activity: CollectionConfig = {
delete: canDeleteActivity,
},
hooks: {
beforeValidate: [setCanonicalActivitySlug, validateActivitySchedule, validateActivityAdvisors],
beforeValidate: [
setCanonicalActivitySlug,
validateActivitySchedule,
validateActivityAdvisors,
validateDeputyPresentTimeGate,
],
beforeChange: [deriveActivityFields],
afterChange: [
async ({ doc, operation, previousDoc, req }) => {
const becameVisible =
operation === 'create'
? doc.status !== 'rascunho'
: previousDoc?.status === 'rascunho' && doc.status !== 'rascunho'
if (!becameVisible) return doc
async ({ doc, req }) => {
// C14: all activities are visible (no more rascunho).
const { notifyActivityNeedsAttention } =
await import('@/utilities/notification/notificationEvents')
await notifyActivityNeedsAttention(req, doc)
@ -325,19 +341,21 @@ export const Activity: CollectionConfig = {
},
},
{
name: 'kind',
type: 'select',
label: 'Tipo de atividade',
required: true,
name: 'tags',
type: 'text',
label: 'Tags',
hasMany: true,
index: true,
options: [...ACTIVITY_KIND_OPTIONS],
admin: {
description: 'Classificação livre do compromisso (ex.: comício, imprensa, reunião).',
},
},
{
name: 'status',
type: 'select',
label: 'Status',
required: true,
defaultValue: 'rascunho',
defaultValue: 'confirmado',
index: true,
access: {
create: canSetActivityStatus,
@ -351,18 +369,6 @@ export const Activity: CollectionConfig = {
label: 'Descrição',
maxLength: 4000,
},
{
name: 'origin',
type: 'select',
label: 'Origem da atividade',
defaultValue: 'dado',
options: ACTIVITY_ORIGIN_OPTIONS,
access: {
create: canManageCampaignStaffField,
read: canReadCampaignStaffField,
update: canManageCampaignStaffField,
},
},
{
name: 'deputyPresent',
type: 'checkbox',
@ -394,16 +400,6 @@ export const Activity: CollectionConfig = {
},
},
},
{
name: 'deadline',
type: 'date',
label: 'Prazo de conclusão',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'municipality',
type: 'relationship',

View file

@ -4,14 +4,17 @@ import Link from 'next/link'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { activityKindLabels } from '@/lib/schemas/activity'
import { formatActivityWhenLabel, type ActivityListViewModel } from '@/utilities/activityViewModels'
export const ActivityCard = ({ activity }: { activity: ActivityListViewModel }) => (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
{activity.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
<ActivityStatusBadge status={activity.status} />
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>

View file

@ -31,14 +31,9 @@ import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import {
activityKindLabels,
activityOriginLabels,
activityStatusLabels,
} from '@/lib/schemas/activity'
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
@ -66,9 +61,91 @@ export type ActivityFormFieldsProps = {
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
searchLeaderships: (query: string) => Promise<ActivityLeadershipOption[]>
/** Tags already used across activities, for autocomplete. */
knownTags?: string[]
}
const editableStatuses = ['rascunho', 'planejado'] as const
const TagInput = ({
initialTags = [],
knownTags = [],
error,
}: {
initialTags?: string[]
knownTags?: string[]
error?: string
}) => {
const [tags, setTags] = useState<string[]>(initialTags)
const [input, setInput] = useState('')
const datalistId = 'activity-tags-datalist'
const addTag = (raw: string) => {
const trimmed = raw.trim().slice(0, MAX_ACTIVITY_TAG_LENGTH)
if (!trimmed) return
if (tags.includes(trimmed)) return
if (tags.length >= MAX_ACTIVITY_TAGS) return
setTags((current) => [...current, trimmed])
setInput('')
}
const removeTag = (tag: string) => {
setTags((current) => current.filter((entry) => entry !== tag))
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="rounded-sm opacity-70 hover:opacity-100"
aria-label={`Remover tag ${tag}`}
>
×
</button>
</span>
))}
{tags.length < MAX_ACTIVITY_TAGS ? (
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
addTag(input)
} else if (event.key === 'Backspace' && !input && tags.length > 0) {
setTags((current) => current.slice(0, -1))
}
}}
onBlur={() => addTag(input)}
placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'}
className="min-h-9 flex-1"
list={datalistId}
maxLength={MAX_ACTIVITY_TAG_LENGTH}
/>
) : null}
<datalist id={datalistId}>
{knownTags
.filter((tag) => !tags.includes(tag))
.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
<input type="hidden" name="tagsJson" value={JSON.stringify(tags)} />
<FieldDescription>
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
</FieldDescription>
{error ? <FieldError>{error}</FieldError> : null}
</div>
)
}
const ActivityFormFields = ({
municipalityOptions,
@ -80,15 +157,9 @@ const ActivityFormFields = ({
submittedTitle,
searchContacts,
searchLeaderships,
knownTags = [],
}: ActivityFormFieldsProps) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const statusIsEditable =
!activity || editableStatuses.includes(activity.status as (typeof editableStatuses)[number])
const [status, setStatus] = useState<(typeof editableStatuses)[number]>(
activity && editableStatuses.includes(activity.status as (typeof editableStatuses)[number])
? (activity.status as (typeof editableStatuses)[number])
: 'rascunho',
)
const [responsible, setResponsible] = useState<ContactComboboxOption | null>(
activity?.responsible
? {
@ -104,11 +175,7 @@ const ActivityFormFields = ({
return (
<FieldGroup>
{statusIsEditable ? (
<input type="hidden" name="status" value={status} />
) : (
<input type="hidden" name="status" value={activity?.status ?? 'rascunho'} />
)}
<input type="hidden" name="status" value="confirmado" />
<Card>
<CardHeader>
@ -134,78 +201,16 @@ const ActivityFormFields = ({
<FieldError id="title-error">{errorFor('title')}</FieldError>
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('kind'))}>
<FieldLabel htmlFor="kind">Tipo de atividade *</FieldLabel>
<NativeSelect
id="kind"
name="kind"
defaultValue={activity?.kind}
required
aria-invalid={Boolean(errorFor('kind'))}
aria-describedby={errorFor('kind') ? 'kind-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Selecione um tipo</NativeSelectOption>
{Object.entries(activityKindLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('kind') ? (
<FieldError id="kind-error">{errorFor('kind')}</FieldError>
) : null}
</Field>
<Field>
<FieldLegend>Status *</FieldLegend>
{statusIsEditable ? (
<>
<ToggleGroup
type="single"
value={status}
onValueChange={(value) => value && setStatus(value as typeof status)}
variant="outline"
className="flex w-full flex-wrap justify-start"
aria-label="Status da atividade"
>
<ToggleGroupItem value="planejado">Planejado</ToggleGroupItem>
<ToggleGroupItem value="rascunho">Rascunho</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
Rascunhos não exigem data. Atividades planejadas exigem data e horário de
início.
</FieldDescription>
</>
) : (
<FieldDescription>
Status atual: {activity ? activityStatusLabels[activity.status] : ''}. Use as
ações do detalhe para marcar como realizado ou cancelado.
</FieldDescription>
)}
</Field>
</div>
<Field data-invalid={Boolean(errorFor('origin'))}>
<FieldLabel htmlFor="origin">Origem da atividade</FieldLabel>
<NativeSelect
id="origin"
name="origin"
defaultValue={activity?.origin ?? 'dado'}
className="w-full **:data-[slot=native-select]:min-h-11 sm:max-w-sm"
aria-invalid={Boolean(errorFor('origin'))}
>
{Object.entries(activityOriginLabels).map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>
Registra se a atividade nasceu da análise de dados, de um pedido de articulação ou
de um compromisso político.
</FieldDescription>
{errorFor('origin') ? <FieldError>{errorFor('origin')}</FieldError> : null}
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
<FieldLabel>Tags</FieldLabel>
<TagInput
initialTags={activity?.tags ?? []}
knownTags={knownTags}
error={errorFor('tagsJson')}
/>
</Field>
<Field data-invalid={Boolean(errorFor('description'))}>
<FieldLabel htmlFor="description">Descrição</FieldLabel>
<Textarea
@ -236,9 +241,9 @@ const ActivityFormFields = ({
<CardTitle>Data e horário</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('startAt'))}>
<FieldLabel htmlFor="startAt">Início {status !== 'rascunho' ? '*' : null}</FieldLabel>
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
<Input
id="startAt"
name="startAt"
@ -246,7 +251,7 @@ const ActivityFormFields = ({
defaultValue={
activity?.startAt ? formatIsoAsBahiaDateTimeInput(activity.startAt) : ''
}
required={status !== 'rascunho'}
required
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
@ -270,23 +275,6 @@ const ActivityFormFields = ({
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('deadline'))}>
<FieldLabel htmlFor="deadline">Prazo de conclusão</FieldLabel>
<Input
id="deadline"
name="deadline"
type="datetime-local"
defaultValue={
activity?.deadline ? formatIsoAsBahiaDateTimeInput(activity.deadline) : ''
}
className="min-h-11"
aria-invalid={Boolean(errorFor('deadline'))}
aria-describedby={errorFor('deadline') ? 'deadline-error' : undefined}
/>
{errorFor('deadline') ? (
<FieldError id="deadline-error">{errorFor('deadline')}</FieldError>
) : null}
</Field>
</div>
</CardContent>
</Card>
@ -463,51 +451,64 @@ export const ActivityForm = ({
municipalityOptions,
organizationOptions,
advisorOptions,
canManageAdvisors = false,
canManageAdvisors,
activity,
submitLabel,
fieldErrors: externalFieldErrors,
submittedTitle,
searchContacts,
searchLeaderships,
knownTags,
submitLabel,
}: ActivityFormFieldsProps & {
action: ActivityFormAction
submitLabel: string
submitLabel?: string
}) => {
const [state, formAction, pending] = useActionState(action, {})
const [state, submitAction, isPending] = useActionState(action, {})
return (
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
{activity ? <input type="hidden" name="id" value={activity.id} /> : null}
{state.message ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível salvar</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
{state.existingHref ? (
<Button asChild variant="outline" className="min-h-11 w-fit">
<Link href={state.existingHref}>Abrir atividade existente</Link>
</Button>
) : null}
<form action={submitAction} className="flex flex-col gap-5">
<ActivityFormFields
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
activity={activity}
fieldErrors={state.fieldErrors}
submittedTitle={state.submittedTitle}
fieldErrors={state.fieldErrors ?? externalFieldErrors}
submittedTitle={state.submittedTitle ?? submittedTitle}
searchContacts={searchContacts}
searchLeaderships={searchLeaderships}
knownTags={knownTags}
/>
{state.existingHref ? (
<Alert variant="destructive">
<AlertTitle>Já existe uma atividade com este título</AlertTitle>
<AlertDescription>
<a
href={state.existingHref}
className="font-medium text-primary underline underline-offset-4"
>
Ver atividade existente
</a>
</AlertDescription>
</Alert>
) : null}
{state.message ? (
<Alert variant="destructive">
<AlertTitle>Nada foi gravado</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button asChild variant="outline" className="min-h-11">
<Link href={activity ? `/campanha/atividades/${activity.slug}` : '/campanha/atividades'}>
Cancelar
</Link>
</Button>
<Button type="submit" className="min-h-11" disabled={pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Salvando…' : submitLabel}
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{submitLabel ?? (activity ? 'Salvar alterações' : 'Criar compromisso')}
</Button>
</div>
</form>

View file

@ -5,8 +5,6 @@ import type { ActivityStatus } from '@/utilities/activityUi'
type BadgeVariant = NonNullable<Parameters<typeof badgeVariants>[0]>['variant']
const statusVariant: Record<ActivityStatus, BadgeVariant> = {
rascunho: 'outline',
planejado: 'secondary',
confirmado: 'default',
realizado: 'estimate-confirmed',
cancelado: 'destructive',

View file

@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/Badge'
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { formatElectionNumber } from '@/lib/electionFormat'
import { activityKindLabels } from '@/lib/schemas/activity'
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
import {
formatGoalCoverageDeficitLabel,
@ -77,7 +77,11 @@ const ActivityListItem = ({ activity }: { activity: ActivityListViewModel }) =>
>
{activity.title}
</Link>
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
{activity.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
<ActivityStatusBadge status={activity.status} />
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>

View file

@ -10,16 +10,8 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
activityKindLabels,
activityOriginLabels,
activityOrigins,
type ActivityKind,
type ActivityOrigin,
} from '@/lib/schemas/activity'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { fieldError } from '@/utilities/campaignFormFields'
import type { TourStopRole } from '@/utilities/visit/visitPlannerViews'
@ -30,9 +22,6 @@ import {
tourStopRoleLabels,
} from '@/utilities/visit/visitPlannerViews'
/** The composition came from the numbers; the coordination changes it per stop. */
const DEFAULT_STOP_ORIGIN: ActivityOrigin = 'dado'
/**
* One candidate stop, already reduced to what the decision needs. Numbers arrive
* formatted and the dossier href arrives built: the canonical serializers drag
@ -45,8 +34,8 @@ export type TourStopOption = {
name: string
role: TourStopRole
suggested: boolean
/** Activity kind the stop generates, derived from its role. */
kind: ActivityKind
/** Activity tags the stop generates, derived from its role. */
tags: string[]
metCount: number
conditionCount: number
unmetConditionLabels: string[]
@ -58,18 +47,13 @@ export type TourStopOption = {
const StopRow = ({
stop,
checked,
origin,
onToggle,
onOriginChange,
}: {
stop: TourStopOption
checked: boolean
origin: ActivityOrigin
onToggle: (checked: boolean) => void
onOriginChange: (origin: ActivityOrigin) => void
}) => {
const checkboxId = `tour-stop-${stop.slug}`
const originId = `tour-origin-${stop.slug}`
return (
<li className="flex flex-col gap-3 p-4 data-[checked=false]:bg-muted/20" data-checked={checked}>
@ -112,30 +96,17 @@ const StopRow = ({
Ver dossiê
<ExternalLinkIcon aria-hidden="true" className="ml-1 inline size-3" />
</Link>
<span className="text-xs text-muted-foreground">
Gera: {activityKindLabels[stop.kind]}
<span className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
Tags:{' '}
{stop.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
</span>
</div>
</div>
</div>
{checked ? (
<Field className="sm:max-w-xs sm:pl-7">
<FieldLabel htmlFor={originId}>Origem desta parada</FieldLabel>
<NativeSelect
id={originId}
value={origin}
onChange={(event) => onOriginChange(event.target.value as ActivityOrigin)}
className="min-h-11 w-full"
>
{activityOrigins.map((value) => (
<NativeSelectOption key={value} value={value}>
{activityOriginLabels[value]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
) : null}
</li>
)
}
@ -166,7 +137,6 @@ export const TourComposerForm = ({
const [selectedSlugs, setSelectedSlugs] = useState<string[]>(() =>
stops.filter((stop) => stop.suggested).map((stop) => stop.slug),
)
const [origins, setOrigins] = useState<Record<string, ActivityOrigin>>({})
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 }))
}
/>
))}
</ul>
@ -231,8 +196,8 @@ export const TourComposerForm = ({
className="min-h-11"
/>
<FieldDescription>
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.
</FieldDescription>
{fieldError(state.fieldErrors, 'tourName') ? (
<FieldError>{fieldError(state.fieldErrors, 'tourName')}</FieldError>
@ -242,7 +207,7 @@ export const TourComposerForm = ({
<FieldLabel htmlFor="tour-note">Nota de janela política</FieldLabel>
<Textarea id="tour-note" name="note" rows={4} maxLength={4000} />
<FieldDescription>
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.
</FieldDescription>
</Field>
@ -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.'}
</p>
</div>
</form>

View file

@ -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<ActivityOrigin, string> = {
dado: 'Baseado em dado',
pedido_broker: 'Pedido de broker',
obrigacao_politica: 'Obrigação política',
}
export const activityKindLabels: Record<ActivityKind, string> = {
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<ActivityStatus, string> = {
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<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
/**
* 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)

View file

@ -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<void> {
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<void> {
// 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";
`)
}

View file

@ -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'
},
];

View file

@ -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<T extends boolean = true> {
export interface ActivitySelect<T extends boolean = true> {
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;

View file

@ -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,

View file

@ -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<string, string | string[] | undefined> = 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<ActivityTab, string[]> = {
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:')) {

View file

@ -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<ActivityTab, string> = {
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<string>(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))

View file

@ -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({

View file

@ -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 } : {}),
},

View file

@ -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,

View file

@ -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()

View file

@ -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<TourStopRole, ActivityKind> = {
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<TourStopRole, string[]> = {
ancora: ['Comício'],
satelite: ['Reunião de apoio'],
semente: ['Reunião de apoio'],
}
/**

View file

@ -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<ActivityCreateData>({
...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)

View file

@ -166,7 +166,7 @@ describe('loadMunicipalitySuggestions (E11)', () => {
collection: 'activity',
data: stub<ActivityCreateData>({
title: fixtures.value('Caminhada Centro'),
kind: 'caminhada',
tags: ['Caminhada'],
status: 'realizado',
startAt: new Date(Date.now() - 10 * DAY_MS).toISOString(),
municipality: municipality.id,

View file

@ -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)
})

View file

@ -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',

View file

@ -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',
)
})

View file

@ -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()