teqo/src/utilities/visitPlannerViews.ts
Francisco Ignácio Fontoura Solla d5b4af2d98 E13: visit eligibility checklist and the tour composer.
Five conditions per município (volume, headroom, rede, janela, encaixe) with
their reason always visible, plus a staff-only composer at
/campanha/atividades/giros that turns an âncora + satélites + semente into draft
activities in one transaction. No `visita` kind and no `tour` entity:
`deputyPresent` already marks the candidate's own agenda.
2026-07-27 21:04:04 -03:00

184 lines
7.3 KiB
TypeScript

import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import type { ActivityKind } from '@/lib/schemas/activity'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
import type { VisitEligibility } from '@/utilities/visitEligibility'
/**
* E13 view models and tour composition — the client-safe contract side of
* `visitPlannerData.ts` (which owns the Payload reads and is `server-only`),
* same split as `votePledgeViews` vs `votePledgeData`.
*/
export type VisitCandidateViewModel = {
id: number
slug: string
name: string
region: BahiaIdentityTerritory
priority: 'alta' | 'normal' | null
eligibility: VisitEligibility
/** Fixed to the `central` scenario, like the E8 card on the detail page. */
coverage: MunicipalityGoalCoverage
territorialClass: MunicipalityTerritorialClass
leadershipCount: number
pledgeCount: number
advisorCount: number
}
export type VisitCandidateGroupViewModel = {
region: BahiaIdentityTerritory
candidates: VisitCandidateViewModel[]
}
export type VisitPlannerBundle = {
phase: CalendarPhase
groups: VisitCandidateGroupViewModel[]
}
/**
* Ordering of the queue: how many of the five conditions a município clears
* first, then how many votes are still missing from its goal. Deliberately not
* a weighted sum of the two — the checklist is a gate, the deficit is the
* tiebreak inside a gate level.
*/
export const compareVisitCandidates = (
left: VisitCandidateViewModel,
right: VisitCandidateViewModel,
): number =>
right.eligibility.metCount - left.eligibility.metCount ||
right.coverage.deficit - left.coverage.deficit ||
left.name.localeCompare(right.name, 'pt-BR')
/** Somewhere the candidate could actually be received. */
const hasNetwork = (candidate: VisitCandidateViewModel): boolean =>
candidate.leadershipCount >= 1 || candidate.pledgeCount >= 1
/** A giro is a day or two: an anchor, a few satellites, one bet. */
export const TOUR_MAX_SATELLITES = 3
export type TourSuggestion = {
/** Município with the largest committed stock — the act that justifies the trip. */
anchorSlug: string | null
satelliteSlugs: string[]
/** One expansão município: P12's "semente" — a bet, not a repeat. */
seedSlug: string | null
}
/**
* J-C composition: âncora + satélites + 1 semente, inside ONE identity
* territory. The anchor is where the committed vote already is (the act has an
* audience), the satellites are the next best stops that have someone to
* receive, and the seed is deliberately a município he is weak in — without it
* every giro repeats the base and the campaign never expands (P12). The seed
* still needs a network: a bet on a place with nobody to receive the candidate
* is not a bet, it is a wasted day (research report §6.7).
*
* The result is a SUGGESTION: the composer renders it pre-selected and the
* coordination edits it before generating anything.
*/
export const composeTourSuggestion = (
candidates: ReadonlyArray<VisitCandidateViewModel>,
): TourSuggestion => {
const receivable = candidates.filter(hasNetwork)
const anchor = receivable.reduce<VisitCandidateViewModel | null>((best, candidate) => {
if (!best) return candidate
if (candidate.coverage.committed > best.coverage.committed) return candidate
if (candidate.coverage.committed < best.coverage.committed) return best
return candidate.coverage.goal > best.coverage.goal ? candidate : best
}, null)
// The seed is reserved BEFORE the satellites are filled. Picking it from the
// leftovers meant a territory with four good stops produced a giro that only
// repeated the base — the satellites had eaten every slot.
const seed =
receivable
.filter(
(candidate) => candidate.territorialClass === 'expansao' && candidate.slug !== anchor?.slug,
)
.sort((left, right) => right.coverage.deficit - left.coverage.deficit)[0] ?? null
const satellites = receivable
.filter((candidate) => candidate.slug !== anchor?.slug && candidate.slug !== seed?.slug)
.sort(compareVisitCandidates)
.slice(0, TOUR_MAX_SATELLITES)
return {
anchorSlug: anchor?.slug ?? null,
satelliteSlugs: satellites.map((candidate) => candidate.slug),
seedSlug: seed?.slug ?? null,
}
}
/** Every slug the suggestion pre-selects, in tour reading order. */
export const tourSuggestionSlugs = (suggestion: TourSuggestion): string[] => [
...(suggestion.anchorSlug ? [suggestion.anchorSlug] : []),
...suggestion.satelliteSlugs,
...(suggestion.seedSlug ? [suggestion.seedSlug] : []),
]
export type TourStopRole = 'ancora' | 'satelite' | 'semente'
export const tourStopRoleLabels: Record<TourStopRole, string> = {
ancora: 'Âncora',
satelite: 'Satélite',
semente: 'Semente',
}
/**
* What each role is for, in the words the coordination uses — rendered next to
* the stop so the composition is auditable instead of magic.
*/
export const tourStopRoleDescriptions: Record<TourStopRole, string> = {
ancora: 'Maior estoque de votos comprometidos do território: é o ato que justifica a viagem.',
satelite: 'Parada com rede para receber, no caminho do mesmo território.',
semente: 'Município de expansão: aposta de crescimento, para o giro não repetir só a base.',
}
export const resolveTourStopRole = (
slug: string,
suggestion: TourSuggestion,
): TourStopRole | null => {
if (slug === suggestion.anchorSlug) return 'ancora'
if (slug === suggestion.seedSlug) return 'semente'
return suggestion.satelliteSlugs.includes(slug) ? 'satelite' : null
}
/**
* The activity kind each role generates. There is no `visita` kind and E13 does
* not add one: `activity.deputyPresent` already IS the candidate-presence
* 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',
}
/**
* A giro is a day or two of the candidate's agenda. The cap is not a UI detail:
* it is what keeps one submit from writing an unbounded batch of drafts.
*/
export const MAX_TOUR_STOPS = 8
/**
* The giro's name is a prefix of every stop's title, so the client's `maxLength`
* and the server's boundary check have to be the same number. It is well under
* the activity title bound (160) plus the longest município name, which is why
* the composed title needs no second check.
*/
export const MAX_TOUR_NAME_LENGTH = 90
/**
* The failures the composer is allowed to show verbatim. They live here, next to
* the cap they describe, because the action that throws them is a `'use server'`
* module (which may only export async functions) and the form action that lists
* them as safe must not spell them a second time.
*/
export const TOUR_EMPTY_MESSAGE = 'Selecione ao menos uma parada do giro.'
export const TOUR_STAFF_ONLY_MESSAGE = 'Apenas a equipe da campanha pode planejar um giro.'
export const TOUR_OUT_OF_SCOPE_MESSAGE =
'Algum município do giro saiu do seu escopo. Atualize a página e monte o giro novamente.'
export const TOUR_MAX_STOPS_MESSAGE = `Um giro aceita no máximo ${MAX_TOUR_STOPS} paradas.`