D-1a — Front-end
Parte da D-1a — Captura / D-1 — Ingestão de Demanda
Este documento especifica a camada de front-end da D-1a: o app que o cidadão usa, as telas, os fluxos de interação e os formulários de captura. Toda interação com o cidadão começa aqui.
O documento cobre o mapa com os fluxos de adição de demanda e lugar, a página de acompanhamento, as empresas, o perfil e o workspace do conselheiro. O detalhamento de uma página acompanha a colônia que a alimenta. Isso evita projetar interface antes de compreender a estrutura de dados disponível.
A camada de servidor (BFF) está especificada em D-1a - BFF.md.
Tela inicial
Seção intitulada “Tela inicial”O app abre direto no mapa. A camada de base é o território da unidade cívica do cidadão, com demandas ativas e lugares mapeados visíveis como pontos georreferenciados.
A exibição de demandas, lugares e polígonos no mapa usa os endpoints públicos de listagem geoespacial da D-7: GET /api/d7/demandas e GET /api/d7/lugares (bounding box do viewport, paginação) e GET /api/d7/uc/poligonos (por nível e UC). A busca roda no moveend com debounce de 400ms, no mount e na reconexão. A fonte única de verdade do mapa é a API: nenhum ponto é injetado localmente a partir do formulário. Após submissão online bem-sucedida ou sincronização da fila offline, o app dispara refetch automático; a confirmação exibe o ID como feedback de UI, não como dado de mapa. O cache offline (IndexedDB) permanece como fallback de leitura. Residências nunca aparecem no mapa: o endpoint público não as retorna (LGPD). Demanda concluída também nunca aparece no mapa: o default de GET /api/d7/demandas já a exclui e o app aplica defesa em profundidade com demandaVisivelNoMapa (src/lib/demanda-mapa.ts), que descarta concluídas na resposta da API, no fallback de falha e no cache IndexedDB, com o log mapa.demandas_concluidas_filtradas quando a leitura remove algum ponto. O que já foi resolvido fica no histórico público da UC.
Busca de unidade cívica e cópia de identificadores. O seletor de UC do acompanhamento, do perfil e do filtro de empresas é um combobox com busca por cidade ou bairro como caminho principal. O campo fica sempre visível, busca a partir de 2 letras com atraso de 300 ms e consome GET /api/d7/uc/poligonos com incluir_geometria=false e limite=10. Cada resultado mostra a sigla da UF antes do nome (ex.: SP · Colina) e, quando a UC tem pai, o nome dele antes do nome da UC (ex.: SP · São Paulo · Pinheiros), mais o nível. A navegação por teclado segue o padrão de combobox (setas, Home, End, Enter e Esc). O UUID continua acessível em um bloco avançado, com botão de copiar. Identificadores de demanda, UC, lugar e do aparelho exibidos no app têm botão de copiar, inclusive com o campo desabilitado ou somente leitura.
Com uma UC selecionada, o chip da selecionada substitui o campo de busca e o bloco de status. O X do chip limpa a seleção, devolve o campo com foco e reabre a busca. O bloco avançado de UUID manual permanece. No painel de unidade cívica, limpar a seleção também limpa os indicadores, o parâmetro da URL e o ID persistido no aparelho.
Operação offline dos pontos do mapa. Sem conexão, o app não tenta buscar pontos na API e não exibe erro. O store consulta o cache IndexedDB (rc-mapa-cache, TTL de 30 dias, até 200 entradas com poda das mais antigas) e mescla as entradas cujas bbox intersectam o viewport, deduplicando por id e filtrando os pontos ao viewport atual. A última bbox consultada é persistida em localStorage (rc_ultima_bbox_mapa) para restaurar os pontos salvos quando o app abre offline. Se a API falhar com o aparelho online, o app também cai para o cache antes de sinalizar erro. O overlay de dados distingue dois estados: com pontos salvos, “Sem conexão. Exibindo pontos salvos no aparelho.”; sem pontos para a área, “Sem conexão. Nenhum ponto salvo para esta área ainda.”. Ao voltar a conexão, o moveend e o evento online refazem a busca e substituem os pontos salvos pelos frescos. Parâmetros espelhados: “Cache offline do mapa” (30 dias) e “Entradas máximas do cache offline do mapa” (200).
Mini-mapa no acompanhamento e deep link do mapa. O card “Situação da demanda” da aba Demanda exibe um mini-mapa somente leitura (pin fixo, sem arrasto) com a coordenada pública da demanda, alimentado por coordenada_lat/coordenada_lng do GET /api/d7/demanda/:demanda_id/resumo. O mini-mapa não aparece para demanda com status agregada (membro de agregação, que sai do mapa público, com link para a demanda representante) nem para removida. O botão “Abrir no mapa” e o link “Ver no mapa” usam o deep link /?lat=&lng=&zoom=, que centraliza o mapa principal na coordenada quando os parâmetros são válidos (dentro da bounding box do Brasil e zoom entre 3 e 19); fora disso, o mapa abre no estado atual. A URL é limpa logo após a leitura, com replace, para não repetir a centralização.
Aviso do relator no acompanhamento. No card “Situação da demanda”, quando o conselheiro_id do snapshot público é igual ao sub do JWT da sessão, o card exibe o aviso “Você é o conselheiro relator desta demanda.” e o botão “Abrir painel do conselheiro”, que leva a /conselheiro. Sem sessão ou com identidade diferente, o aviso não aparece. A leitura do sub vive em src/lib/auth/jwt.ts (extrairCidadaoIdDoJwt).
Datas em horário de Brasília e timeline inversa. Toda data exibida no app usa o fuso America/Sao_Paulo, sem sufixo de fuso (15/07/2026 11:00). Datas puras (YYYY-MM-DD, como a data estimada de prazo do conselheiro) são exibidas como literais, sem conversão de fuso. O “hoje” dos formulários e o limite máximo do datepicker seguem o dia de Brasília, e os filtros data_inicio/data_fim do acompanhamento e do relatório da UC são enviados como o dia de Brasília (T00:00:00-03:00 e T23:59:59.999-03:00); a API continua devolvendo instantes ISO-8601 em UTC. A timeline da aba Demanda exibe os eventos mais recentes primeiro (a API ordena em cronologia inversa) e o botão de paginação é “Carregar anteriores”; a timeline do relatório da demanda permanece em cronologia crescente. Evento de processo usa o card padrão com ícone por tipo; conselheiro.atualização_publicada usa card-mensagem-conselheiro.tsx sobre o bloco mensagem-pessoa.tsx, com autor “Conselheiro relator” em selo, selo do tipo da atualização e o texto em citação itálica com aspas, para não se confundir com status do sistema. O texto do card vem de dados_relevantes.texto_completo, com fallback para o descricao de 200 caracteres, e a atualização transcrita leva o selo “Transcrição automática” quando origem_texto é automatico. No relatório da UC, o título de cada demanda e cada membro do agregado são links para /acompanhamento/:demanda_id, com o ID abreviado e botão de copiar.
Voz de pessoa nas descrições e mensagens. Texto escrito por pessoa usa o bloco mensagem-pessoa.tsx (barra lateral roxa, autor em selo com ícone, selos roxos e ciano, data opcional e corpo em citação itálica com aspas). Na timeline o autor é heading (como="h3"); nos resumos e no relatório é parágrafo. A nota de autoria fica sem itálico, em texto de comentário, para separar a fala da pessoa do texto do sistema. A descrição pública da demanda leva o título “Descrição pública”, o selo “Cidadão” e a nota “Texto escrito por quem registrou a demanda, não pelo sistema.” no acompanhamento, no resumo do conselheiro e no relatório da demanda, inclusive na impressão. A descrição do lugar no painel do mapa leva o selo “Cidadão” e a nota “Texto escrito por quem registrou o lugar, não pelo sistema.”. O texto nunca é renderizado como HTML.
Descrição automática das imagens. O texto gerado a partir das fotos fica em bloco próprio, separado da descrição pública. O bloco descricao-automatica-imagens.tsx leva o título “Descrição automática das imagens”, o selo “Gerada por IA”, a nota “Texto gerado automaticamente a partir das imagens desta demanda. Não é texto escrito pelo cidadão.” e uma linha por imagem descrita, com a tradução quando aplicada. O bloco renderiza apenas quando há evidência de imagem com descrição e ignora item de áudio, imagem sem descrição e descrição em branco. Aparece depois da descrição pública no acompanhamento e no resumo do conselheiro e antes das evidências no relatório da demanda, com cartao-quebra-pagina quando longo. Cada miniatura de evidência usa a descrição no alt, com corte de 160 caracteres e fallback “Evidência da demanda”, na miniatura e no lightbox. No sheet do mapa o detalhe recebe apenas o alt, sem o bloco. O preview da moderação usa a descrição do card no alt, com o mesmo fallback.
Acompanhamento, unidade cívica e histórico na mesma página. A página /acompanhamento reúne as leituras em três abas: Demanda (timeline pública, aba padrão), Unidade cívica (indicadores agregados da D-7) e Histórico (demandas concluídas da unidade cívica). O parâmetro uc é compartilhado entre as abas de unidade cívica e histórico: a seleção em uma reflete na outra e a troca de aba preserva a UC. A aba de histórico nunca fica desabilitada; sem UC, o painel mostra o SeletorUc e o convite para escolher. A rota /dashboard redireciona para /acompanhamento?aba=uc, preservando o uc da URL. O switch da barra superior alterna entre mapa e acompanhamento. Deep links de demanda usam /acompanhamento/:demandaId; links de unidade cívica usam ?aba=uc&uc=; links de histórico usam ?aba=historico&uc=.
Na barra superior, três controles:
-
Esquerda: menu hambúrguer. Abre o menu principal com as seguintes entradas:
Entrada Descrição Mapa Tela inicial. Exibe demandas e lugares georreferenciados. Fluxos de adição acessíveis pelos botões flutuantes. Acompanhamento Página única com três abas. Demanda: timeline pública da captura à conclusão. Unidade cívica: indicadores agregados da D-7. Histórico: demandas concluídas da UC, com filtros e paginação. Depende da D-7 (Transparência). Conselheiro Workspace do conselheiro: cadastro com declaração de capacitação, atribuição pendente, acompanhamento ativo com registro de atualizações e ratificação da conclusão. Exige sessão Google (JWT). Detalhamento na seção “Página do conselheiro”. Empresas Listagem de empresas cadastradas, diagnósticos salariais e simulações econômicas. Detalhamento na seção “Página de empresas”. Perfil Dados do cidadão, vínculos e histórico de contribuições. A leitura de perfil exige login Google (JWT); o dispositivo anônimo vê o convite de vinculação. Detalhamento na Fase 2 com gov.br. -
Centro e direita: um switch para alternar entre mapa e acompanhamento. A aba de unidade cívica do acompanhamento é uma projeção simples gerada pela D-7.
Em telas pequenas, o logo “Rede Cívica” mostra só o ícone; o texto aparece a partir do breakpoint sm. O nome acessível do logo vem de aria-label, então não depende do texto visível. O switch mapa/acompanhamento mantém os rótulos de texto em todas as larguras, porque os ícones sozinhos não são compreensíveis. O ajuste evita o corte do logo quando o hambúrguer e o switch dividem a barra em celulares.
No canto inferior direito, dois botões flutuantes de adição, sempre visíveis:
- Ícone de demanda (símbolo a definir): abre o fluxo de “Adicionar demanda pública”.
- Ícone de GPS: abre o fluxo de “Adicionar lugar”.
O modelo visual segue o padrão Waze: mapa em tela cheia, ações de registro acessíveis em um toque, categoria escolhida em segundos. A geolocalização e o horário são capturados automaticamente pelo dispositivo. O cidadão não precisa digitar endereço nem informar em qual bairro está.
Acompanhamento em tempo real. Com o mapa aberto e a localização ativada por toque no botão de GPS, o app mantém o watchPosition vivo e atualiza o pino e o círculo de precisão a cada fix aceito (filtro de 5 m de deslocamento ou ganho de 20% de precisão; fixes acima de 5000 m são descartados), com o mapa seguindo o deslocamento no zoom escolhido pelo cidadão. Arrastar o mapa desliga o seguimento; um novo toque no botão religa e recentraliza. A posição é processada apenas no aparelho: nenhuma coordenada é transmitida ao servidor pelo acompanhamento. O watch pausa quando a aba fica em segundo plano, retoma quando ela volta a ficar visível (se a localização estava ativa) e encerra ao sair do mapa; a centralização silenciosa do boot é pontual, sem watch. O moveend do seguimento não dispara re-busca na D-7 enquanto o viewport estiver dentro da última bbox carregada (guarda de re-busca), e o pré-cache de tiles segue o deslocamento a cada 500 m.
Comportamento dos controles superiores durante adição. Quando o cidadão toca em um dos botões de adição, a modal de categorias sobe do fundo e o switch de alternância desaparece temporariamente, deixando apenas o hambúrguer visível. O foco se concentra exclusivamente no fluxo de adição até a conclusão.
Controles do mapa e tela cheia. A pilha do canto superior direito do mapa reúne localização, camadas, régua de medição, filtros e tela cheia, nessa ordem. O botão de tela cheia usa a Fullscreen API sobre o documento inteiro: o cabeçalho continua visível e o próprio botão vira a saída, com aria-pressed e rótulo alternando entre “Entrar em tela cheia” e “Sair da tela cheia”. O estado é observado por fullscreenchange e falhas da API são registradas sem quebrar a interface. Em navegadores sem suporte (iOS Safari), o botão não é renderizado.
Altura dinâmica e área segura no mobile. O layout usa height: 100dvh em html, body e #root, com fallback em 100%, e o <meta name="viewport"> usa viewport-fit=cover. Com isso, as barras inferiores de navegadores como o Brave no Android não cobrem os controles: os créditos do rodapé do menu, o card de medição, o aviso de offline, o aviso de privacidade e o FAB respeitam a área segura (env(safe-area-inset-bottom)). Os sheets e diálogos recebem o mesmo recorte, e os sheets inferiores limitam a altura em dvh no mobile, mantendo vh apenas a partir de lg.
Mini-mapas em contexto de empilhamento próprio. O mini-mapa de localização da demanda e do lugar e o mapa do relatório da demanda usam relative isolate no container. Os z-index internos do Leaflet (panes e controles, de 400 a 1000) ficam contidos no próprio mapa e não competem com os overlays do app, em especial o slide do menu hambúrguer.
Primeiro acesso. Na primeira visita, o app abre um diálogo único de boas-vindas antes de qualquer pedido de permissão ou consentimento. O diálogo organiza o conteúdo em três abas. A aba 1, “Mapa e demandas”, lista quatro itens (o que aparece no mapa, como registrar sem cadastro, como confirmar o que é real e como acompanhar até a conclusão) e o botão “Explorar o mapa”, que conclui o onboarding e navega para o mapa. A aba 2, “Empresas”, lista a leitura pública, o diagnóstico salarial por cargo, o excedente com destino e o resultado no território, com o botão “Explorar empresas”, que conclui o onboarding e navega para /empresas. A aba 3, “Ciclo fechado”, percorre as sete etapas do ciclo, do mapeamento no território ao retorno do resultado, com o acompanhamento de um conselheiro relator entre a gestão e a incubadora, sem botão de destino. O botão “Fechar”, o X e a tecla Escape fecham o diálogo. O onboarding é registrado no dispositivo com versão (rc_onboarding_versao) e não reaparece até o conteúdo mudar de versão. O cartão de aviso de privacidade aparece depois que o onboarding fecha, ainda no primeiro acesso, e mantém o caráter não bloqueante. A entrada “Como funciona” no menu lateral reabre o onboarding a qualquer momento.
Fluxo 1 — Adicionar demanda pública
Seção intitulada “Fluxo 1 — Adicionar demanda pública”O cidadão toca em “Adicionar demanda pública”. O mapa permanece ao fundo. Um painel deslizante (slider) sobe a partir da base da tela.
Passo 1 — Seleção de área temática (ícones em grid)
Seção intitulada “Passo 1 — Seleção de área temática (ícones em grid)”O painel exibe um grid de ícones coloridos em fundo cinza claro. Cada ícone representa uma área temática. Aproximadamente 17 áreas, carregadas do backend.
O grid usa 2 colunas em telas com menos de 400 px e 3 colunas a partir de 400 px. A regra vale também para as subcategorias (Passo 2). Os cards não têm altura forçada por linha: a linha cresce conforme o conteúdo, com altura mínima de 112 px e quebra de palavras nos rótulos longos. O corpo do sheet usa altura máxima em dvh e rolagem própria. A partir do breakpoint sm, o cabeçalho e o grid ficam centralizados com largura máxima de 42 rem. A partir de lg, o próprio painel é centralizado, limitado à mesma largura e com altura máxima de 85 vh.
┌──────────────────────────────────────────────────┐│ [💧 Água] [⚡ Energia] [🏠 Moradia] ││ [🍎 Aliment] [💊 Saúde] [🛡️ Segurança] ││ […] […] […] │└──────────────────────────────────────────────────┘Cada ícone tem rótulo curto abaixo. O cidadão toca no ícone da área que descreve o problema. A seleção fecha o primeiro nível e abre o segundo imediatamente.
As áreas temáticas:
| Área | Categorias cobertas |
|---|---|
| Água, esgoto e drenagem | 1.1, 1.2, 1.7 |
| Energia e iluminação | 1.3, 3.4 |
| Moradia e abrigo | 1.4, 1.6 |
| Alimentação | 1.5 |
| Saúde | 2.1, 2.2, 2.3, 2.4 |
| Segurança | 2.5, 2.6, 2.7, 2.8 |
| Limpeza urbana | 3.3 |
| Ruas, calçadas e trânsito | 3.1, 3.2, 3.10 |
| Transporte público | 3.5 |
| Educação | 3.6, 3.7, 3.8 |
| Praças e espaços públicos | 3.9 |
| Conectividade | 3.11 |
| Trabalho e capacitação | 4.1, 4.2 |
| Cultura, esporte e lazer | 4.3, 4.4 |
| Meio ambiente e animais | 4.6, 4.7, 4.8, 5.2 |
| Turismo | 4.5 |
| Inovação, transparência e participação | 5.1, 5.3, 5.4 |
A lista completa de categorias, subcategorias e seus códigos está em D-3 - Taxonomia.md. Os categoria_id usados aqui são os mesmos do documento de referência. Ícones, rótulos e ordem de exibição são definidos no backend. O front apenas renderiza o que recebe.
Passo 2 — Seleção de subcategoria (ícones em grid)
Seção intitulada “Passo 2 — Seleção de subcategoria (ícones em grid)”O painel atualiza o conteúdo no lugar. O título muda para o nome da área escolhida e um botão de voltar aparece no canto superior direito. A grade exibe os ícones das subcategorias daquela área no mesmo padrão responsivo do Passo 1: 2 colunas abaixo de 400 px e 3 colunas a partir de 400 px, sem altura forçada por linha.
Exemplo para “Água, esgoto e drenagem”:
┌──────────────────────────────────────────────────────────────┐│ Água, esgoto e drenagem ← voltar ││ [🚱 Falta d'água] [☣️ Contaminação] [🚽 Esgoto] ││ [💧 Pressão baixa] [📍 Sem ligação] […] │└──────────────────────────────────────────────────────────────┘O cidadão toca na subcategoria que melhor descreve o problema. A seleção abre a tela final antes do envio.
Cada subcategoria carrega subcategoria_id e categoria_id correspondentes, vindos do backend. O cidadão não vê esses códigos. Vê apenas o ícone e o rótulo.
Passo 3 — Descrição formal e detalhes da demanda
Seção intitulada “Passo 3 — Descrição formal e detalhes da demanda”Com a subcategoria selecionada, abre a tela final de registro. O painel exibe:
Descrição formal da subcategoria (informativo, não editável). Exemplo para subcategoria “Falta d’água”: “Falta de água, interrupção no fornecimento, contaminação da rede, pressão insuficiente, ausência de ligação domiciliar”. Esta descrição vem do backend e serve para o cidadão confirmar que selecionou corretamente antes de prosseguir.
Campos de entrada:
- Título inferido (pré-preenchido pelo app com base na subcategoria, editável). Exemplo: “Falta d’água na rua”.
- Descrição (texto livre, opcional). Campo aberto para o cidadão detalhar o que está vendo.
- Evidências (opcional). Botões para acionar câmera, microfone ou galeria. O cidadão pode adicionar foto ou áudio. Vídeo ficou fora do contrato da Fase 1.
- Localização (automática). Exibida no mini-mapa com pin. O cidadão pode arrastar o pin para ajustar a posição se o GPS não estiver preciso. O zoom funciona pelos botões +/− e pela rolagem do mouse, com o mesmo controle suave do mapa principal (
ZoomSuaveMapa, limiar de 40 px por notch) e ancoragem no cursor. O pin arrastável tem nome acessível.
Campos obrigatórios mínimos na API: localização + (título ou descrição ou evidência). categoria_id/subcategoria_id são opcionais no contrato; o fluxo de UI guia a seleção.
Botão de confirmação (ação final). Texto: “Enviar demanda” ou equivalente. Ao tocar, o front-end envia a requisição ao BFF.
Passo 4 — Envio e confirmação
Seção intitulada “Passo 4 — Envio e confirmação”O botão “Enviar demanda” encerra o fluxo no front-end. A requisição é enviada ao BFF. Após validação e publicação do evento, o BFF retorna a confirmação com o demanda_id para acompanhamento.
Sugestão de candidatas antes do envio. Antes de submeter, o app chama POST /api/d12/candidatas com o texto, a categoria e a localização informados. A busca não bloqueia o envio: falha de rede ou estado offline pula a sugestão e segue o submit normal, que permanece como caminho garantido. Quando a busca retorna candidatas, o app abre um sheet com as demandas equivalentes: título, distância, contador de confirmações e dois botões. “Confirmar esta demanda” abre o fluxo de confirmação da demanda existente e descarta o envio novo. “Minha demanda é diferente” segue o submit() normal.
Os sheets do ciclo de envio ficam centralizados e com largura máxima de 42 rem no desktop, com altura máxima de 85 vh, no mesmo padrão dos sheets de detalhe. A regra vale para a confirmação da demanda enviada, a confirmação do lugar enviado, as candidatas parecidas, a confirmação de demanda existente, a conclusão da demanda, a conclusão pelo conselheiro e o registro de atualização.
Confirmação pelo mapa (detalhe do marcador)
Seção intitulada “Confirmação pelo mapa (detalhe do marcador)”O detalhe do marcador de demanda tem três blocos. O topo reúne o título, a categoria, o status em badge colorido (concluída verde, em andamento laranja, agendada/atribuída ciano, removida vermelha, agregada roxa, demais cinza) e a data de registro. O corpo carrega o resumo do agregado sob demanda: quando a demanda é representante, lista os membros com link para a página de acompanhamento; quando é membro, aponta para o representante; e exibe as evidências públicas do agregado (miniaturas de imagem com lightbox, alt com a descrição da imagem e player de áudio nativo). O rodapé reserva a base inteira para o botão de validação social.
O detalhe do marcador abre em um sheet inferior em todas as telas, com cabeçalho fixo e corpo rolável. No desktop o sheet fica centralizado e com largura máxima. O ícone do pino usa o ícone da subcategoria escolhida pelo cidadão; a taxonomia é carregada quando o mapa abre, então o ícone resolve mesmo em um primeiro acesso sem cache.
O botão de validação social ocupa a largura total, com ícone, o texto “Confirmar que vi isso” e o contador de confirmações ao lado. Ao tocar, o app usa a posição já conhecida do geo-store e reage à distância em três faixas. Até 300 m, o rótulo permanece “Confirmar que vi isso (N)”. Entre 301 m e 700 m, vira “Confirmar (Você está próximo) (N)”. Acima de 700 m, o botão fica desabilitado com a mensagem “Aproxime-se a menos de 700 m do local para confirmar este problema.” Com posição válida, a confirmação é enviada na hora e o contador soma um de forma otimista, reconciliado com o total da resposta. Confirmado, o botão vira “Você confirmou isto” com fundo verde e fica desabilitado. Sem posição de GPS, o clique abre o sheet de confirmação. Quando a precisão da posição passa de 1000 m, o detalhe exibe o banner de localização imprecisa.
O estado confirmado persiste no aparelho em localStorage (rc_confirmacoes_demandas) e sobrevive ao fechamento do detalhe e a novas sessões no mesmo device. O 409 mantém o estado confirmado sem somar no contador. Timeout ou 429 caem na fila offline, com o incremento otimista mantido.
O sheet de confirmação continua como caminho guiado: resumo da demanda, aviso de mídia pública com consentimento (mesmo padrão do envio da captura, art. 11, I), localização atual do cidadão via geo-store, captura de evidência opcional reutilizando CapturaMidia com a compressão existente e o envio via POST /api/d12/demandas/:demanda_id/confirmacoes. O sucesso atualiza o contador no detalhe. O 409 vira a mensagem “Você já confirmou esta demanda”. Timeout ou 429 caem na fila offline.
Login no momento decisivo. As duas primeiras confirmações e as duas primeiras conclusões anônimas são aceitas. A partir da terceira, a API responde 401 com code: 'login_necessario' e o app troca o envio pelo CTA de login Google no botão de validação social, no sheet de confirmação e no sheet de conclusão. O login retoma a ação bloqueada. O 401 limpa a confirmação ou a conclusão persistida no aparelho (rc_confirmacoes_demandas ou rc_conclusoes_demandas) e expõe o CTA sem erro genérico. Na fila offline, o item sincronizado no limiar é descartado com o log fila_offline.login_necessario, sem consumir as três tentativas, e o botão volta a ficar acionável com o CTA.
Fluxo 2 — Adicionar lugar
Seção intitulada “Fluxo 2 — Adicionar lugar”O cidadão toca no botão com ícone de GPS. O mapa permanece ao fundo. A mesma modal sobe da base da tela, agora com o fluxo de cadastro de lugar. O switch de alternância mapa/acompanhamento desaparece, deixando apenas o hambúrguer visível para manter o foco.
O que é um lugar
Seção intitulada “O que é um lugar”Um lugar é qualquer elemento físico com localização no território: uma residência, uma farmácia, um mercado, uma escola, uma praça, um terreno vazio, uma UBS, uma padaria, um galpão industrial. O mapeamento de lugares constrói a camada de realidade territorial que o sistema usa para calcular gaps e identificar onde faltam organizações.
O conceito é análogo ao que apps como Google Maps e Waze fazem ao permitir que usuários adicionem estabelecimentos. A diferença está no uso dos dados: aqui o mapeamento alimenta análise de cobertura territorial.
Passo 1 — Seleção de tipo de lugar (ícones em grid)
Seção intitulada “Passo 1 — Seleção de tipo de lugar (ícones em grid)”O painel exibe um grid de 2 colunas com ícones coloridos em fundo cinza claro, representando os tipos de lugar disponíveis. O app registra apenas organização e equipamento público. O tipo residência fica fora do fluxo de adição para manter o mapa limpo. A API permanece aceitando tipo_lugar: 'residencia' no POST /api/lugares e a D-7 continua sem projetar residências no mapa público (LGPD).
Organização: comércio local (mercado, farmácia, padaria), serviço (oficina, salão, clínica), produção (fábrica, galpão, horta), social (ONG, associação, cooperativa), institucional (escola particular, academia).
Equipamento público: saúde (UBS, hospital), educação (escola, creche, biblioteca), assistência (CRAS, albergue), infraestrutura (praça, parque, ciclovia), segurança (delegacia, base PM), administração (prefeitura, subprefeitura).
O grid de tipos de lugar mantém 2 colunas em qualquer largura. Os cards seguem sem altura forçada por linha, com altura mínima de 112 px e quebra de palavras nos textos longos. O corpo do sheet usa altura máxima em dvh e fica centralizado com largura máxima a partir do breakpoint sm; a partir de lg, o próprio painel é centralizado, limitado à mesma largura e com altura máxima de 85 vh.
Passo 2 — Detalhes do lugar
Seção intitulada “Passo 2 — Detalhes do lugar”Com o tipo selecionado, abre a tela de registro do lugar:
- Nome do lugar (obrigatório).
- Descrição (texto livre, opcional).
- Foto do local: não suportada pela API atual (o
CriarLugarDtonão tem campo de mídia). O fluxo de lugar é texto + localização. - Localização (automática, com pin ajustável no mini-mapa).
- Horário de funcionamento (opcional, relevante para organizações e comércios).
O horário de funcionamento não é texto livre no formulário. O cidadão seleciona os dias da semana em botões de alternância e informa abertura e fechamento em campos de hora. Os atalhos “Dias úteis”, “Fim de semana”, “Todos os dias” e “Limpar horário” preenchem a seleção. O app serializa a escolha para texto antes de enviar (ex.: Seg a Sex, 08:00 às 18:00) e mostra a prévia do que será salvo. O contrato não muda: horario_funcionamento continua string de até 200 caracteres.
Campos obrigatórios mínimos: tipo de lugar selecionado + nome + localização.
Botão de confirmação (ação final). Texto: “Registrar lugar” ou equivalente. Ao tocar, o front-end envia a requisição ao BFF.
O sheet de confirmação do lugar enviado segue o padrão de desktop dos demais sheets do fluxo, centralizado e com largura máxima de 42 rem a partir do breakpoint lg.
O mini-mapa do lugar tem três características. O zoom funciona pela rolagem do mouse, com o mesmo controle suave do mapa principal, além dos botões +/−. O pin usa o mesmo formato de pin da demanda, com a cor e o glifo do tipo de lugar (🏪 organização, 🏛️ equipamento público) e a ponta ancorada na coordenada. Os polígonos de prédios aparecem desde o zoom 15 no mini-mapa de lugar, para servir de referência ao cadastro; no mapa geral os prédios seguem a partir do zoom 16. A exceção do mini-mapa está registrada nos espelhos de parâmetros (“Zoom mínimo dos prédios no mini-mapa de lugar”).
Painel de detalhes do lugar
Seção intitulada “Painel de detalhes do lugar”O clique no marcador de lugar abre um sheet inferior com o resumo público do lugar, no mesmo padrão do detalhe da demanda. O painel consome GET /api/d7/lugar/:lugar_id/resumo e exibe nome, ícone e rótulo do tipo, subtipo, badge de status de confiança, data de registro, unidade cívica com link para o acompanhamento, descrição como citação com o selo “Cidadão” e a nota de autoria, horário, tipificação da L-2 e total de confirmações. O lugar provisório aparece no mapa com marcação distinta (contorno tracejado e cor atenuada); o confirmado mantém o marcador cheio.
O resumo devolve 404 para lugar disputado ou desativado. O painel mostra a mensagem de indisponibilidade e não oferece ações.
Ações sociais do lugar
Seção intitulada “Ações sociais do lugar”O rodapé do painel reúne três ações: confirmação, denúncia e retirada.
Confirmação. O botão “Confirmar que existe” segue o padrão da validação social da demanda. Usa a posição do geo-store, exibe o contador e reage à distância em três faixas: até 300 m presencial, entre 301 m e 700 m “Você está próximo”, acima de 700 m desabilitado com a explicação. Sem posição, o toque solicita a localização. A confirmação envia POST /api/l3/lugares/:lugar_id/confirmacoes com a posição atual. O total sobe de forma otimista e é reconciliado com a resposta. Confirmado, o botão vira “Você confirmou este lugar” e o aparelho guarda o estado em localStorage (rc_confirmacoes_lugares).
Denúncia. O botão “Denunciar este lugar” abre um sheet com quatro motivos em rádio (nao_existe, dados_incorretos, duplicado, inadequado), a posição atual e o envio POST /api/l3/lugares/:lugar_id/denuncias. A denúncia é anônima na leitura pública: nenhuma identificação do denunciante entra em projeção. O estado fica guardado em localStorage (rc_denuncias_lugares).
Retirada. O botão “Remover meu registro” abre um diálogo de confirmação e envia POST /api/l3/lugares/:lugar_id/retirada. Só o autor remove; terceiros recebem 403 com mensagem neutra. A retirada é online apenas: sem conexão, o app explica a exigência e não enfileira.
Fila offline. Confirmação e denúncia entram na fila de submissão com os tipos confirmacao_lugar e denuncia_lugar. O processamento usa X-Cidadao-Id sem o segredo de dispositivo, remove o item no 409 e respeita o backoff em 429. Após o sucesso, o painel recarrega o resumo e o mapa; o lugar disputado ou retirado sai do mapa.
Fluxo 3 — Desenhar polígono de unidade cívica
Seção intitulada “Fluxo 3 — Desenhar polígono de unidade cívica”Este fluxo fica para a Fase 2. No MVP, o sub-botão não existe no FAB e a captura via API (POST /api/lugares com tipo_lugar: 'poligono_uc') permanece disponível e inerte. A especificação abaixo vale para a Fase 2.
O cidadão toca no terceiro botão flutuante (ícone de polígono). O mapa permanece ao fundo. Este fluxo é o mecanismo de bootstrap territorial: cidadãos desenham os contornos dos bairros e setores que o sistema ainda não conhece. Sem esses polígonos, o point-in-polygon da D-2 não encontra UC de níveis 1-2 e as demandas são resolvidas apenas no nível municipal (nível 4).
O que é um polígono de UC
Seção intitulada “O que é um polígono de UC”Um polígono de unidade cívica é o contorno geográfico de um território: um bairro, um setor, uma micro-área. É um anel fechado de coordenadas GPS. O formato é GeoJSON Polygon, o mesmo tipo que o sistema usa em core.uc_polygons.
Não é um lugar pontual como um estabelecimento. É uma área. O cidadão não clica em um ponto, ele desenha o contorno.
Passo 1 — Ativar modo de desenho
Seção intitulada “Passo 1 — Ativar modo de desenho”O cidadão toca no botão de polígono nos controles flutuantes. O mapa entra em modo de desenho:
- O cursor muda para crosshair
- Uma barra de ferramentas aparece no topo do mapa: “Desenhar bairro/setor” com botões Desfazer | Refazer | Cancelar | Concluir
- O painel inferior desliza com instrução: “Toque nos vértices do contorno. Feche o polígono tocando no primeiro ponto.”
- Uma camada de referência é ativada: os polígonos de UC já existentes aparecem com opacidade reduzida, para que o cidadão veja onde termina um bairro e começa outro
Passo 2 — Desenhar o polígono
Seção intitulada “Passo 2 — Desenhar o polígono”O cidadão toca em pontos no mapa para adicionar vértices. A cada toque, o segmento é desenhado. O último vértice conecta ao primeiro, fechando o polígono. O cidadão pode arrastar vértices existentes para ajustar.
Validações no front-end (antes do envio):
- Área mínima: o polígono deve ter área ≥ 100m² (evita toques acidentais)
- Sem auto-intersecção: o anel não pode cruzar a si mesmo. Detectado por
turf.kinks()e sinalizado visualmente - Vértices mínimos: ao menos 3 (triângulo)
- Vértices máximos: 200 (evita polígonos excessivamente detalhados que sobrecarregam o point-in-polygon)
Componente de referência: react-leaflet-draw (https://github.com/alex3165/react-leaflet-draw). Fornece a barra de ferramentas de desenho, edição de vértices, undo/redo e exportação do polígono como GeoJSON. Alternativa: leaflet-draw diretamente via hook customizado se o wrapper React estiver desatualizado.
Passo 3 — Identificar o território
Seção intitulada “Passo 3 — Identificar o território”Com o polígono desenhado, o painel deslizante exibe campos de identificação:
- Nome do território (obrigatório). Ex: “Jardim das Flores”, “Setor 4 — Vila Mariana”
- Nível (obrigatório, seleção por toggle). Opções: Nível 1 (setor/micro-área) ou Nível 2 (bairro). O nível 2 só pode ser selecionado se o polígono estiver contido em um polígono de nível 4 (município), verificado pelo servidor. Se não houver município cadastrado, o nível 2 fica bloqueado com mensagem: “Cadastre o município primeiro.”
- Unidade cívica pai (obrigatório para nível 2, automático para nível 1). Para nível 2, o cidadão seleciona o município (nível 4) que contém o bairro. Para nível 1, o sistema detecta automaticamente qual bairro (nível 2) ou município (nível 4) contém o polígono, via point-in-polygon contra
core.uc_polygons.
Passo 4 — Envio
Seção intitulada “Passo 4 — Envio”O polígono é submetido como POST /lugares com tipo especial:
{ tipo_lugar: 'poligono_uc', subtipo: string, // string livre (100); a API não valida enum de nível nome: 'Jardim das Flores', geometria: { type: 'Polygon', coordinates: [...] }, // GeoJSON // unidade_civica_pai: NÃO existe no contrato atual; removido do payload cidadao_id: '...', canal: 'app'}O BFF publica lugar.recebido no barramento com esses campos. A L-1 valida e publica lugar.cadastrado. No MVP, um script administrativo insere manualmente o polígono em core.uc_polygons após revisão. Na Fase 2, a colônia de infraestrutura geoespacial automatiza a inserção.
Passo 5 — Confirmação e visibilidade
Seção intitulada “Passo 5 — Confirmação e visibilidade”Após o envio, o polígono aparece no mapa com borda tracejada e opacidade reduzida, sinalizando que está pendente de revisão. Um tooltip informa: “Território em análise. Será integrado ao mapa em até X dias.” Quando revisado e integrado a core.uc_polygons, a borda fica sólida e o polígono passa a ser usado para point-in-polygon.
Sobreposição com polígonos existentes
Seção intitulada “Sobreposição com polígonos existentes”Se o novo polígono tem sobreposição > 80% com um polígono já existente em core.uc_polygons, o front-end exibe um alerta antes do envio: “Este território parece já estar mapeado como [nome do polígono existente]. Deseja sugerir uma correção da borda?” O cidadão pode enviar mesmo assim. A versão alternativa fica registrada e a colônia geo decide o canônico.
Se a sobreposição for entre 50-80%, o alerta é informativo: “Parte deste território coincide com [nome]. Verifique se é uma subdivisão ou correção.”
Estado offline
Seção intitulada “Estado offline”O desenho de polígonos funciona offline. O GeoJSON é armazenado na fila local (IndexedDB) e enviado quando a conexão retorna. Os polígonos de referência (UCs já existentes) são cacheados junto com os tiles do mapa.
Evolução do front-end
Seção intitulada “Evolução do front-end”O detalhamento das páginas segue a ordem de especificação das colônias. Cada página depende de uma ou mais colônias para ter estrutura de dados definida. Projetar a interface antes de compreender o dado disponível gera retrabalho.
Foco atual: mapa com os dois fluxos de adição (demanda e lugar), a página única de acompanhamento, as empresas, o perfil e o workspace do conselheiro (seção própria). A taxonomia de categorias e subcategorias que alimenta os grids de seleção já está definida na D-3 (D-3 - Taxonomia.md).
Ordem de evolução prevista:
| Ordem | Página | Depende de | Situação |
|---|---|---|---|
| 1 | Mapa + adição | D-3 (Taxonomia) | No MVP |
| 2 | Acompanhamento (demanda e unidade cívica) | D-6b, D-7 | No MVP |
| 3 | Conselheiro | D-6a, D-6b, BFF D-1a | No MVP (seção “Página do conselheiro”) |
| 4 | Empresas | E-1, E-2, E-3 | No MVP (seção “Página de empresas”) |
| 5 | Perfil | Google OAuth (MVP) / D-9 (Fase 2) | Básico no MVP, completo na Fase 2 |
Cada página do MVP tem detalhamento neste documento. Quando uma colônia for escrita, o ciclo é: escrever a colônia, compreender a estrutura de dados e especificar as telas que a consomem.
Página do conselheiro
Seção intitulada “Página do conselheiro”A página /conselheiro é o workspace do conselheiro no app, com entrada própria no menu. Todas as ações exigem sessão Google (JWT com auth_provider=google): sem sessão, a página mostra o convite de login. A página consome src/types/conselheiro.ts e src/services/conselheiro.ts, com os hooks use-situacao-conselheiro e use-acompanhamento-conselheiro.
Explicação do papel. A página explica o papel do conselheiro em um card único (src/components/conselheiro/explicacao-conselheiro.tsx), com Accordion de quatro itens: o que é um conselheiro, como funciona o sorteio, bairro ou cidade e recusa, segurança e ciclo. O card abre com a frase-resumo “O papel, o sorteio, a cobertura da sua unidade cívica e as regras de recusa e segurança.”. O conteúdo é visível sem sessão Google, sem bloquear o cadastro. O texto segue os parâmetros públicos (1 demanda ativa, ciclo de 18 meses, limite de 3 recusas e suspensão de 90 dias).
A página tem quatro estados, resolvidos a partir de GET /d6a/conselheiros/me/situacao e GET /d6b/conselheiros/me/acompanhamento:
1. Cadastro. Sem cadastro na D-6a, a página mostra o formulário cadastro-conselheiro.tsx: seletor de unidade cívica com valor inicial da uc_residencia do perfil, texto curto de capacitação, checkbox de declaração de conclusão e o botão “Quero ser conselheiro”. O envio chama POST /api/conselheiros com capacitacao_concluida e nivel_atuacao igual ao nível da UC escolhida. Sem a UC resolvida, o envio fica bloqueado e a seleção exibe o nível com a dica de cobertura (a unidade cívica cobre ela mesma e as unidades menores dentro dela). A capacitação é por declaração no MVP.
2. Cadastrado, sem atribuição. Cadastrado e sem demanda sorteada, a página mostra o status (disponivel) e aguarda o sorteio. Suspenso, mostra a data fim da suspensão. A recusa com motivo risco_pessoal aparece no diálogo de recusa com o aviso de que não penaliza.
3. Atribuição pendente. Com atribuição ativa na D-6a e sem acompanhamento iniciado, a página mostra o card atribuicao-pendente.tsx com a data do sorteio e o resumo da demanda (GET /api/d7/demanda/:demanda_id/resumo), o botão “Recusar” (diálogo com recusa_explicita ou risco_pessoal, que chama POST /api/conselheiros/atribuicoes/:demanda_id/recusar) e o botão “Começar acompanhamento”. O aceite é local no aparelho e leva ao acompanhamento ativo; não existe evento de aceite no catálogo. O resumo (resumo-demanda-conselheiro.tsx) traz título (fallback para o UUID abreviado), status, categoria, subcategoria com a descrição formal, descrição pública como citação com o selo “Cidadão” e a nota de autoria, o bloco “Descrição automática das imagens” com o selo de IA depois da descrição pública, contadores, última atualização em bloco de citação, aviso de conteúdo removido, mini-mapa somente leitura e os links diretos “Ver demanda completa”, “Relatório da demanda” e “Abrir no mapa”. As evidências usam a descrição no alt.
4. Acompanhamento ativo. Com acompanhamento na D-6b, a página mostra acompanhamento-ativo.tsx: o mesmo resumo da demanda, lista das últimas atualizações, prazos com status, pendência de ratificação com o botão “Ratificar conclusão” (POST /d6b/demandas/:demanda_id/ratificar-conclusao, tratando 403, 404 e 409 com mensagens neutras) e as ações “Registrar atualização” e “Concluir demanda”. Cada atualização leva status: publicada, aguardando_transcricao ou falha_transcricao. A pendente mostra o aviso da transcrição em andamento e o botão “Verificar status”, que relê o acompanhamento. A falha mostra a orientação de registrar o relato por texto. A publicada a partir de transcrição mostra o selo “Texto automático” com a confiança registrada pela D-6b, e cada atualização publicada usa o bloco de citação de pessoa com o tipo, a origem e a data.
O registro de atualização (registrar-atualizacao.tsx) seleciona o tipo entre os seis valores da D-6b, exibe os campos estruturados por tipo e o texto do relato. O botão opcional “Sugerir estruturação” chama POST /d6b/sugerir-estruturacao e mostra o texto original e a sugestão lado a lado, com o score_confianca visível; o conselheiro edita antes de publicar. A publicação chama POST /api/conselheiros/atualizacoes com origem_estruturacao coerente com o uso da sugestão. Falha da IA não bloqueia o envio manual.
O relato em áudio usa gravador-relato.tsx, que grava com MediaRecorder (limite de 5 MB e 180 s) e envia o objeto pelo presigned URL. Com o áudio, o envio leva audio_object_key, o texto pode vir vazio e a resposta é imediata. A transcrição roda em segundo plano: o texto é automático e a atualização aparece na timeline quando fica pronta. O áudio não é público e fica restrito à auditoria. A tela de sucesso informa o envio, a transcrição em andamento, o texto automático, o caráter restrito do áudio e a publicação na timeline quando o texto ficar pronto. A conclusão de demanda envia uma atualização status_atualizado com novo_status: concluido, sem áudio, com aviso de irreversibilidade. No sucesso, o sheet fecha e a página anuncia “Demanda concluída. O ciclo do conselheiro foi encerrado.”, com recarga em segundo plano; o registro de atualização mantém a tela de sucesso com o botão “Fechar”.
As ações do conselheiro são online: sem conexão, os botões ficam bloqueados com mensagem clara. Não existe fila offline para cadastro, recusa ou atualizações no MVP.
Página de empresas
Seção intitulada “Página de empresas”A página /empresas pesquisa por nome com atraso de 300 ms, filtra por setor, porte, status e unidade cívica no servidor, pagina de 20 em 20 com “Carregar mais” e mantém o bloco avançado de busca por UUID, que navega para /empresas/:empresaId. O card de cada empresa é um link para o detalhe, com ícone do setor, nome fantasia (com fallback para a razão social), cidade e UF da sede e badges de porte e status. Os estados de carregamento, vazio, erro com nova tentativa e offline com aviso estão cobertos; a listagem não tem cache offline e o aviso informa que os resultados já carregados permanecem na tela.
A página /empresas/:empresaId consome GET /api/empresas/:id, o diagnóstico e o histórico da E-2 e as simulações da E-3 (GET /api/empresas/:id/simulacoes). Exibe perfil institucional, endereços com “Ver no mapa” pelo deep link, unidades cívicas, diagnóstico com leitura em linguagem simples e a simulação econômica com excedente, destino entre fomento, reinvestimento e trabalhadores e histórico paginado. ID inválido ou inexistente cai no estado de não encontrada, com botão voltar. Gráficos usam figure com resumo textual e tabelas usam caption, com os valores sempre em texto, nunca só por cor.
A lista de setores vem do GET /api/setores, com cache local de 24h (rc_setores) e fallback ao id cru. As rotas são de leitura pura.
Aba de histórico do acompanhamento
Seção intitulada “Aba de histórico do acompanhamento”A consulta pública das demandas concluídas de uma unidade cívica é a terceira aba de /acompanhamento, somente leitura e sem dados pessoais. Não há página própria: as rotas /historico e /historico/:ucId redirecionam para /acompanhamento?aba=historico e /acompanhamento?aba=historico&uc=:ucId, no mesmo padrão de /dashboard e /dossie/:demandaId. O consumo é GET /api/d7/uc/:uc_id/historico por src/services/historico.ts, com parser estrito do envelope e das linhas, tipagem em src/types/historico.ts e estado em use-historico (carregar e carregarMais).
O painel (src/components/acompanhamento/painel-historico.tsx) recebe a UC pelo parâmetro uc e devolve a seleção por callback. O SeletorUc busca por nome a partir de 2 letras, com os atalhos territoriais do acompanhamento, e o aviso fixa a cobertura: a visão inclui a unidade escolhida e todas as unidades menores dentro dela. Os filtros de categoria, título e período de conclusão são aplicados no servidor; o período usa o dia de Brasília, como no relatório da UC. A lista pagina de 20 em 20 com “Carregar mais” enquanto houver página, e cada item mostra título, data de conclusão, dias até a conclusão, total de confirmações e categoria, com links para /acompanhamento/:demanda_id e /relatorio-demanda/:demanda_id.
Os estados cobrem carregando, vazio (“Nenhuma demanda concluída encontrada para esta unidade cívica.”), erro com “Tentar novamente”, offline com o aviso “Histórico indisponível offline.” (a aba não usa o cache do mapa), UC inválida e o convite para escolher uma unidade cívica quando não há UC na URL.
A entrada é a aba “Histórico” ao lado de “Demanda” e “Unidade cívica”. O painel da UC não tem botão de histórico; o botão “Relatório da unidade cívica” renderiza somente com UC válida.
Decisões de stack
Seção intitulada “Decisões de stack”Framework de UI: Vite + React + TypeScript
Seção intitulada “Framework de UI: Vite + React + TypeScript”Vite como build framework. React não depende de Next.js ou Remix para este projeto. Não há SSR, não há SEO, não há landing page. O app é um PWA map-first client-side. Vite entrega HMR instantâneo, bundle otimizado, PWA via vite-plugin-pwa e TypeScript nativo, sem a complexidade de um segundo servidor de aplicação (o backend já é o BFF em NestJS). Types e interfaces de payload compartilhados entre front e BFF eliminam uma classe inteira de bugs de contrato.
Design System: shadcn/ui + Tailwind CSS
Seção intitulada “Design System: shadcn/ui + Tailwind CSS”shadcn/ui copia o código fonte dos componentes para dentro do projeto. Botões, modais, dropdowns e selects são arquivos que você possui e edita. Por baixo, usa Radix para acessibilidade e interações complexas (trap de foco, navegação por teclado). Tailwind resolve CSS em times pequenos sem cascata acidental. O visual não fica com “cara de framework”.
Biblioteca de mapa: Leaflet + react-leaflet + MapLibre (tiles vetoriais self-hosted)
Seção intitulada “Biblioteca de mapa: Leaflet + react-leaflet + MapLibre (tiles vetoriais self-hosted)”Leaflet é a integração mais madura com React para mapas open source. O ecossistema de plugins cobre clustering de demandas (MarkerCluster) e heatmap. Consome GeoJSON nativamente. As malhas do IBGE e os polígonos das unidades cívicas entram direto, sem transformação. A camada base usa tiles vetoriais gerados do OpenStreetMap e servidos por infraestrutura própria (martin), renderizados pelo MapLibre GL como overlay dentro do Leaflet; a camada de satélite usa a Esri. O provedor de tiles é trocável: mudar a URL de tiles não altera uma linha de código.
Mapbox GL JS v2 foi descartado. Não é open source. A licença é proprietária, exige conta e token, e o lock-in é profundo. O fork livre é MapLibre GL (BSD), compatível com o mesmo formato de vector tiles, sem conta e sem custo.
Leaflet aguenta com folga a Fase 1 (bairro, centenas de pontos). Com MarkerCluster, chega a alguns milhares. A partir da Fase 2 (escala regional, múltiplos municípios agregados no mesmo mapa), o volume de pontos pode ultrapassar dezenas de milhares. Nesse patamar, Leaflet baseado em DOM/SVG atinge o limite. MapLibre GL com WebGL renderiza centenas de milhares de pontos sem degradação. A migração é planejada para a Fase 2. O gatilho é o primeiro dashboard regional que agregar múltiplos municípios. Ambos consomem GeoJSON. Trocar o renderizador não altera o formato dos dados.
Rótulos do mapa. Os rótulos fixos da camada base são cinco camadas por classe e faixa de rank: estados desde o z2 do estilo, em caixa alta e Noto Sans Bold; capitais desde o z3; cidades com corte de rank (≤ 4 no z4, ≤ 5 no z5, ≤ 6 no z6 e sem corte do z7 em diante); vilas e distritos no z7; e bairros e ilhas no z12. Os limites de UF têm a camada limites-estados sobre boundary, com admin_level: 4, no z3. A entrada usa text-opacity em fade de 0,4 a 1 em 0,6 zoom e text-padding de 4 a 6. Os rótulos de estabelecimentos pré-marcados do OpenStreetMap (farmácia, posto, loja, escola e demais classes da camada poi) estão fora dos dois estilos, no mapa principal e no mini-mapa. O maplibre-gl-leaflet renderiza o mapa interno um nível abaixo do zoom do Leaflet, então cada entrada aparece um nível acima do valor do estilo. Parâmetros públicos nos dois espelhos.
Demarcação dos estados pela malha do IBGE. Os estados têm preenchimento e contorno próprios a partir da malha de UFs do IBGE embarcada no web (src/lib/map/estados-brasil.json, 27 feições simplificadas com o mapshaper). O preenchimento vai do z2 do estilo (z3 do mapa) com fade até o z9, e o contorno cobre o z2 e o z3, antes dos limites tracejados dos tiles. A malha vem do mesmo seed IBGE da API, funciona offline e entra nos créditos do menu.
Desenho de polígonos: react-leaflet-draw (Fase 2)
Seção intitulada “Desenho de polígonos: react-leaflet-draw (Fase 2)”O fluxo de desenho de polígonos de unidade cívica usa react-leaflet-draw (wrapper React para leaflet-draw), na Fase 2. O plugin fornece barra de ferramentas de desenho/edição, criação de vértices via toque no mapa, undo/redo e exportação do polígono como GeoJSON. O GeoJSON gerado é o mesmo formato esperado por core.uc_polygons, sem transformação. A validação de geometria (área mínima, auto-intersecção, fechamento do anel) usa @turf/turf no front-end, a mesma biblioteca usada pela D-2 no back-end para point-in-polygon, garantindo consistência de algoritmo entre validação e uso.
Cobertura para as demais telas do MVP
Seção intitulada “Cobertura para as demais telas do MVP”A stack foi escolhida para o app completo. Cada tela tem primitivo mapeado:
| Tela (dependência) | O que a stack oferece |
|---|---|
| Mapa + adição (demanda/lugar) | Leaflet + react-leaflet para renderização, Zustand para estado dos fluxos de adição; o desenho de polígonos de UC entra na Fase 2 com react-leaflet-draw |
| Acompanhamento (D-7) | Abas com Radix Tabs; timeline com cards de status em React puro; Zustand para estado dos indicadores agregados, Tailwind para layout responsivo, shadcn/ui + Recharts para gráficos e cards de indicadores |
| Histórico (D-7) | SeletorUc reutilizado, lista paginada em React puro, filtros com os primitivos de formulário e use-historico para o estado, na terceira aba do acompanhamento |
| Conselheiro (D-6a/D-6b) | Página com estados de sessão (convite de login, carregando, erro); formulários com React Hook Form; seletor de UC reutilizado; diálogos Radix para recusa; aria-live nos estados assíncronos |
| Empresas (E-1/E-2/E-3) | React Hook Form em modo wizard para cadastro multi-etapas, shadcn/ui para tabelas de diagnóstico salarial e simulação econômica |
| Perfil (Fase 2) | Formulários com React Hook Form, upload de documento com Dropzone |
A stack escala de 1 tela para 10 sem mudança arquitetural. Vite faz code splitting automático conforme páginas são adicionadas ao bundle.
Autenticação: Device ID anônimo no MVP
Seção intitulada “Autenticação: Device ID anônimo no MVP”Barreira zero para o primeiro uso. O app gera um UUID v4 na primeira abertura, armazena em localStorage e o envia como X-Cidadao-Id nas rotas que identificam o cidadão: POST /api/demandas, POST /api/lugares, ações sociais do lugar e consulta de anexos. Nas rotas de captura, o app também envia o X-Device-Segredo, o segredo de dispositivo gerado localmente e guardado como hash pela API. O cidadão nunca vê tela de login, nunca cria conta, nunca digita senha. Abre o app no mapa e começa a usar.
Essa abordagem é viável porque o projeto não tem o conceito de “minha demanda”. Demandas são públicas. O que um cidadão faz é reportar um problema no território. O vínculo entre cidadão e demanda é apenas o ato de reportar. Não há posse.
Reports idênticos de cidadãos DIFERENTES não se agregam na captura: o hash de idempotência inclui o cidadão_id, então cada cidadão cria sua própria demanda. A agregação existe pela D-12 (Detecção de Duplicidade): a captura sugere candidatas antes do envio e o detalhe do marcador oferece a confirmação pelo botão de validação social. A agregação final acontece por confirmação coletiva de cidadãos distintos, nunca na captura.
O cidadão_id derivado do device serve para idempotência e correlação de comportamento (base para o sistema de pontos e reputação). O rate limiting usa o IP do cliente. O identificador não serve para identificar uma pessoa real. Na Fase 2, com gov.br, o usuário pode opcionalmente vincular o device ID a uma identidade verificada. Reports anteriores ao vínculo migram junto.
Limitação conhecida: limpar o localStorage ou trocar de dispositivo perde o vínculo com os reports anteriores. As demandas continuam no sistema. Só não aparecem como “reportadas por este dispositivo”. Para MVP de bairro, aceitável.
Login social opcional: para quem quiser um perfil persistente entre dispositivos (necessário para se candidatar a conselheiro e acumular histórico de ciclos), o app oferece login com Google via @react-oauth/google. Device ID permanece o padrão. O login social é um upgrade voluntário, não uma barreira de entrada. Reports feitos antes do login são migrados para a conta vinculada.
Menu lateral. O rodapé do menu mostra o estado da sessão. Sem login, o botão oficial do Google ocupa a largura disponível, medida do container para não estourar o painel. O container do widget é recortado para esconder a moldura branca que o GSI desenha ao redor do botão; a área branca que resta é o quadro oficial do logo do Google. Com login, o bloco exibe um avatar com a inicial do nome, o nome do titular, o e-mail quando existir e um botão “Sair” de largura total. O nome e o e-mail vêm da resposta do login e ficam salvos na sessão local (rc_auth_perfil). Quando a sessão não tem esse registro, o app busca GET /api/cidadaos/me uma vez para hidratar o bloco. O avatar é a inicial do nome, nunca a foto do Google.
Estado: Zustand
Seção intitulada “Estado: Zustand”Mínimo boilerplate. Suficiente para estado do mapa (filtros, camadas ativas, coordenadas atuais) e formulário de captura (categoria selecionada, passos concluídos). Mais estruturado que Context puro, drasticamente menos verboso que Redux.
Formulários: React Hook Form
Seção intitulada “Formulários: React Hook Form”O fluxo de adição de demanda tem 3-4 passos em sequência. React Hook Form lida com formulários de múltiplos passos, validação integrada e evita re-renderização desnecessária. É a lib dominante no ecossistema React para formulários.
Roteamento: React Router
Seção intitulada “Roteamento: React Router”O app usa react-router-dom com createBrowserRouter. O mapa é a rota raiz (/), e as páginas seguem em rotas próprias: /acompanhamento, /acompanhamento/:demandaId, /relatorio/:ucId, /relatorio-demanda/:demandaId, /empresas, /empresas/:empresaId, /perfil, /conselheiro, /meus-dados, /moderacao e /diagnostico. A rota /dashboard redireciona para /acompanhamento?aba=uc e /dossie/:demandaId redireciona para /relatorio-demanda/:demandaId. As rotas /historico e /historico/:ucId redirecionam para /acompanhamento?aba=historico e /acompanhamento?aba=historico&uc=:ucId. Os fluxos de adição continuam como modais sobrepostos ao mapa, não como rotas.
Offline-first: PWA com fila de submissão local
Seção intitulada “Offline-first: PWA com fila de submissão local”O app funciona sem conexão de internet desde o MVP. Muitos municípios brasileiros têm cobertura de rede instável ou ausente em áreas periféricas, exatamente onde o mapeamento é mais necessário. O app não pode depender de conectividade contínua para cumprir sua função básica: registrar demandas e lugares.
PWA com Service Worker. O app é um Progressive Web App com service worker configurado via vite-plugin-pwa. A estratégia de cache é cache-first para o shell do app (HTML, JS, CSS, ícones) e network-first para dados (API). O service worker pré-cacheia os assets necessários para o app abrir e funcionar offline.
Ícones do PWA. Os ícones exibidos pelo navegador e na instalação usam fundo transparente, com só o símbolo do pino visível: favicon-solid.svg e favicon.svg (16 e 32px) e icon.svg (PNGs 192 e 512, purpose any). O fundo escuro fica restrito às variantes maskable e ao apple-touch-icon, que exigem área cheia para a máscara do Android e o arredondamento do iOS. O script scripts/generate-icons.mjs gera todos os PNGs a partir dos SVGs.
Fila de submissão local (IndexedDB). Quando o cidadão submete uma demanda ou lugar sem conexão, o payload é armazenado em uma fila persistente no IndexedDB do navegador. A fila tem os campos id, tipo (demanda, lugar, confirmacao, conclusao, confirmacao_lugar ou denuncia_lugar), payload (JSON do DTO completo), midia (Blobs já comprimidos), status (pendente, enviando, enviado ou erro), criado_em e tentativas. Payload e mídia são cifrados com AES-GCM antes de gravar, com chave por instalação; itens em erro expiram em 30 dias. O service worker ou a aplicação, ao detectar reconexão, percorre a fila em ordem e envia cada item ao BFF. Cada envio bem-sucedido remove o item da fila e notifica o cidadão. Itens de confirmação e conclusão de demanda entram na mesma fila. No processamento, os Blobs de evidência sobem primeiro por presigned URL + PUT e só depois a confirmação é enviada; o 409 remove o item e o considera sincronizado, no mesmo padrão do 409 de demanda.
Pré-cache de tiles do mapa. Os tiles do mapa são pré-cacheados quando há conexão ao redor da última posição de GPS (buffer de 2 km, re-disparo a cada 500 m) e, quando não há GPS, ao redor do centro do mapa a partir do zoom 13 (re-disparo a cada 1 km). O pré-cache ignora tiles já salvos e o cache do service worker mantém até 3000 entradas por 30 dias. O mapa offline mostra o território conhecido. Fora da área cacheada, o fundo fica cinza, e o cidadão ainda pode registrar demanda com coordenadas de GPS, que funciona offline. Sem conexão, a camada vetorial nunca troca para o fallback OSM: o fallback (3 erros consecutivos ou 15 s sem tiles) só vale com rede, e ao reconectar o app retoma a camada vetorial. Com o fallback ativo e conexão, o app também tenta a camada vetorial de novo a cada 60 segundos (INTERVALO_RETENTATIVA_VETOR_MS), o que recupera o vetorial sozinho depois de uma indisponibilidade temporária do martin, sem exigir recarregar a página. Parâmetros espelhados: “Pré-cache de tiles”, “Entradas do cache de tiles do service worker” e “Retentativa da camada vetorial no fallback”.
Tile ausente e contexto mundial. O source vetorial cobre o Brasil e os vizinhos da América do Sul no z0–16, no mesmo arquivo e sob o mesmo id do martin, com ruas, água, uso do solo, limites, cidades e rótulos, sem prédios e sem verde da Overture fora do Brasil. Ele é servido por um protocolo custom do MapLibre (rcvetor, em src/lib/map/protocolo-tiles-vetoriais.ts): a resposta 204 do martin ou um corpo vazio viram a sentinela ErroTileAusente, registrada como mapa.tile_ausente e na métrica tiles_ausentes_total, sem contar erro de servidor. O fallback para o OSM considera a viewport: quando o mapa fica ocioso (idle) com os tiles carregados e sem feições do source brasil, o watchdog de 15 s é re-armado; a presença de feições do source mantém a camada vetorial. Fora da América do Sul, o estilo injeta o contexto mundial do Natural Earth 50m embarcado (src/lib/map/mundo/): terra, água, fronteiras e rótulos de país até o z7 do estilo, com a América do Sul fora das fronteiras e dos rótulos do Natural Earth. Os países da América do Sul têm rótulo próprio da camada place dos tiles (class == country) antes dos estados. Parâmetros espelhados: “Detecção de tile ausente”, “Contexto mundial do mapa” e “Rótulos de países”.
Resolução de conflitos. Demandas são append-only por natureza. Não há edição concorrente de um mesmo registro. O pior caso offline é duplicata: o cidadão submete a mesma demanda, perde conexão, o app enfileira, a conexão volta, o envio ocorre, mas o cidadão não vê a confirmação e submete de novo. A D-1a trata idempotência por hash de conteúdo + cidadão_id, sem componente temporal. O segundo envio retorna HTTP 409 com o demanda_id original. O front-end trata HTTP 409 como “já enviado” e remove o item da fila sem criar duplicata.
Estado da rede. O aviso “Sem conexão” sobre o mapa é o único sinal visual do estado da rede. Não há indicador de sincronização na interface.
Modo de operação offline. O fluxo de adição de demanda e lugar funciona integralmente offline. A seleção de categoria e subcategoria usa a taxonomia cacheada (carregada do BFF e armazenada em localStorage, com TTL de 24h). A foto é tirada e armazenada localmente até o envio. As coordenadas vêm do GPS do dispositivo, que funciona offline. Upload de mídia para o MinIO é adiado: o arquivo fica em blob local e é enviado quando a conexão retorna, antes do POST da demanda ao BFF.
Limitações conhecidas do modo offline no MVP:
- A timeline de acompanhamento e os indicadores da unidade cívica não funcionam offline (dependem de dados frescos do servidor). A aba de histórico também não funciona offline: ela avisa e não usa o cache do mapa. O mapa funciona offline com os pontos salvos no aparelho, mesclados por viewport a partir do cache de 30 dias.
- O login Google não funciona offline. O device ID anônimo, que é o fluxo padrão, funciona.
- A deduplicação offline depende do hash no servidor. A sugestão de candidatas exige conexão. Offline, o cidadão envia sem sugestão; a agregação com demandas equivalentes só se resolve depois, pela confirmação coletiva.
- A confirmação de demanda funciona offline: a confirmação direta do detalhe e a confirmação com evidência entram na fila e sincronizam na reconexão. Sem conexão, o contador e as evidências do detalhe ficam desatualizados até o refetch.
- A confirmação e a denúncia de lugar funcionam offline e entram na fila (
confirmacao_lugaredenuncia_lugar). A retirada do lugar exige conexão e não enfileira.
Referência
Seção intitulada “Referência”Camada irmã: D-1a - BFF.md. Especificação técnica completa em Apêndice B - Colônias.md, seção “D-1a — Captura”. Schema de categorias: D-3 - Taxonomia.md.