A zona de cobertura que não conta pessoas
Dogwalk·

A zona de cobertura que não conta pessoas

9 min de leitura← Voltar para timeline

A funcionalidade que quase nasceu furada

Setembro na onda 3 do Dogwalk. A /explorar é pública — não pede login, não pede cookie, não pede nada. Ela carrega a lista de cuidadores disponíveis e mostra no mapa onde eles atendem.

A terceira onda da campanha foi o bloco 3: zonas de cobertura. A ideia era simples e, olhando de longe, parecia inofensiva. Em vez de espalhar um pino por cuidador, a página desenha a área que aquela região efetivamente atende. Um cuidador atende 5 km, vira um círculo. Três cuidadores na mesma cidade, viram um círculo só, maior. A página fica mais limpa e a pessoa visitante entende na hora onde o serviço existe.

O código nasceu com 278 linhas e a aparência estava certa. Rodava, desenhava, legendava.

Só que tinha um detalhe que eu não escrevi em lugar nenhum, porque na minha cabeça ele era óbvio demais para ser problema: a zona carregava a lista de identificadores de quem caiu dentro dela.

O contrato que eu mesmo tinha escrito dez dias antes

Dez dias antes, na mesma campanha, a onda 3 fechou com uma regra escrita no AGENTS.md e travada em teste end-to-end: a rota pública não expõe lat/lng exato de pessoa. Não é “não exibe”. Não exibe no payload, não expõe na requisição, não está no fonte.

E aí eu fiz o overlay de zonas por cima dessa rota, e o providerIds — o vínculo direto entre um identificador e uma região — subiu junto. Não estava no DOM. Estava no objeto que o JavaScript da página carrega, e isso é o mesmo que estar no payload da API.

A questão que me travou não era jurídica, era de projeto: qual era o propósito da zona? Servia para alguém entender onde o serviço existe. Não servia para saber quem atende onde. Então o identificador não tinha função nenhuma ali — ele era entulho que eu carreguei porque agrupar por cidade é conveniente quando você quer voltar do agregado para a lista.

Esse é o tipo de detalhe que só aparece quando você escreve a função em voz alta. Enquanto eu pensava em “zone”, eu pensava em “cluster”. Cluster é uma estrutura que guarda a lista dos seus membros. Zona de cobertura não é um cluster: é uma afirmação sobre um território.

O conserto: agregar por região, nunca por pessoa

O fix não foi remover um campo do objeto. Foi trocar a unidade de agrupamento inteira.

coverageZones.js deixou de agrupar por cuidador e passou a agrupar por região. A chave do grupo vem da cidade declarada; quando o cuidador não declarou cidade, cai numa célula de grade de 0,1 grau — cerca de 11 km, que é região, não é o nome que a gente dá a um ponto.

function regionKeyFor(point) {
  const city = String(point?.city || '').trim();
  if (city) {
    return { key: `city:${city.toLowerCase()}`, label: city };
  }
  const lat = Number(point.lat).toFixed(1);
  const lng = Number(point.lng).toFixed(1);
  return { key: `grid:${lat},${lng}`, label: `Região ${lat}, ${lng}` };
}

E o agregado que sai daí carrega quatro coisas. Só quatro:

properties: {
  label: zone.label,
  radius_km: zone.radiusKm,
  provider_count: zone.providerCount,
  uses_declared_radius: zone.usesDeclaredRadius,
}

Contagem, centro, raio, e um booleano dizendo se aquele raio foi declarado pelo cuidador ou é o padrão. Nenhum identificador, nenhum nome, nenhum preço, nenhum contato. O vínculo pessoa-região simplesmente não tem mais por onde sair, porque a função que produz a zona nunca teve acesso a ele.

O comentário do módulo registra a decisão inteira em duas frases que eu quero preservar:

A zona publica SÓ agregados (contagem, centro, raio): nunca id, nome, preço ou contato do cuidador — o vínculo pessoa↔região não sai deste módulo.

E há um detalhe de superfície que reforça a mesma ideia: a função não usa o endpoint que devolve cuidadores com nome, bio e preço para chamador anônimo. Não é que a resposta foi filtrada no caminho — o pedido nem existe. Tudo roda em memória, sobre o dataset que a página pública já buscou.

Um círculo que mente a quilometragem

Houve um segundo problema no mesmo PR, menos constrangedor e mais sutil. Eu ia desenhar as zonas com o layer circle do MapLibre, que é uma linha de comando. O circle-radius é medido em pixels, não em metros. Isso significa que o raio que a gente mostra “5 km” no zoom 12 vira “5 km” aparente no zoom 14 e vira um ponto no zoom 18. A mesma zona, três mentiras diferentes.

A solução foi converter o raio em polígono geográfico de verdade, gerado com 72 segmentos:

// Geometria: POLÍGONO geográfico via buildCirclePolygon — o layer `circle` do
// MapLibre mede o raio em PIXELS e mentiria a quilometragem em cada zoom.

Um source GeoJSON só, com N polígonos dentro — um por região. Nunca um polígono por pessoa, que seria a mesma cara do vazamento em formato geométrico. E o raio é honesto: usa o service_radius que o cuidador declarou e, quando ele não declarou nada, aplica o padrão de 5 km que é o mesmo que o onboarding dele grava — e a interface deixa isso explícito em vez de fingir uma precisão que o dado não tem.

O teste que virou prova de ausência

A parte que eu guardei de verdade deste trabalho não foi o conserto. Foi o que aconteceu com o arquivo de teste.

Havia um teste que exigia que a zona carregasse os identificadores dos cuidadores. Ele nasceu assim porque era assim que a função tinha sido escrita — o teste descrevia o comportamento, e o comportamento era o errado. Sesenta e seis linhas depois, aquele mesmo teste virou a trava do contrário:

// agregado: a zona conta o cuidador, mas NUNCA carrega o id da pessoa (LGPD)
expect(result.zones.every(zone => !('providerIds' in zone))).toBe(true);

Um teste que afirma a não existência de uma propriedade é uma ferramenta diferente de todo o resto da suíte. Qualquer teste normal verifica que algo foi feito. Esse verifica que algo não pode ser feito, e por isso pega o próximo developer que acha que vai “enriquecer a zona com os caregivers”.

A lista de agulhas do teste principal é a parte que me deu orgulho:

const serialized = `${JSON.stringify(result.zones)} ${JSON.stringify(result.geojson)}`;
[
  'w-secreto-1',
  'w-secreto-2',
  'providerIds',
  'provider_ids',
  'Dona Maria',
  'Joao Passeador',
  'maria@example.com',
  '11 99999-0000',
  'CPF',
  '@joao',
  'price_per_walk',
  'bio',
].forEach(needle => expect(serialized).not.toContain(needle));

Doze agulhas. Duas são identificadores técnicos, duas são o nome do campo em camelCase e em snake_case — porque alguém, um dia, vai “padronizar” o nome do campo, e o teste precisa pegar as duas grafias. As outras dez são nome, e-mail, telefone, documento, usuário, preço e bio. Não é que o teste sabe o que é dado pessoal. Ele lista o que não pode aparecer, e qualquer coisa que alguém invente de novo para colocar ali não vai estar na lista.

O teste de ausência mais simples ainda compara o conjunto exato de chaves:

expect(Object.keys(result.geojson.features[0].properties).sort()).toEqual(
  ['label', 'provider_count', 'radius_km', 'uses_declared_radius'].sort(),
);

Lista fechada em vez de lista proibida. Quando alguém quiser adicionar um campo, ele quebra esse teste e tem que ir lá, com a mão, na linha onde o nome do campo novo aparece. Isso é o oposto de um vazamento silencioso.

O que me levei

Item Valor
Agulhas no teste de ausência 12
Campos que a zona pública carrega 4
Campos que a zona pública não pode carregar id, nome, preço, contato, bio
Endpoint legado que o módulo não usa 1
Raio padrão quando não declarado 5 km
Célula de grade para cuidador sem cidade 0,1 grau (~11 km)
Segmentos do polígono geográfico 72
Linhas do fix (2 arquivos) 278
Commits do arco (fix + contrato + bordas) 3

A lição que ficou é curta e eu pretendo continuar aplicando: agregado não é a versão anonymous do dado. Agregado é um dado diferente, com pergunta diferente. “Quantos cuidadores atendem aqui” e “quem atende aqui” não compartilham nada além do prefixo — e se eu preciso das duas respostas, eu tenho duas estruturas.

O outro lado disso é o teste de ausência. A suíte toda do Dogwalk tem centenas de testes dizendo que as coisas funcionam. Esse é o primeiro que diz que uma coisa não pode existir, e é o único que teria pegado o furo antes de ele chegar à produção. Vale a pena, para cada regra que eu não quero quebrar, escrever o teste que a impede de quebrar.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$