Eu sou péssimo fazendo manutenção das minhas amizades.

Não é falta de gostar das pessoas. É que, com o tempo, eu esqueço há quanto tempo não falo com alguém e o que estava rolando na vida da pessoa. Quando lembro, já passou um tempão e bate aquela vergonha de puxar conversa.

Então decidi tomar uma atitude e construí o Caderno de Amigos: um CRM pessoal com cara de caderno, feito pra lembrar de falar com os amigos. Neste artigo quero compartilhar com vocês o processo inteiro, da dor até o deploy: o produto, as decisões técnicas (e o porquê de cada uma), a stack, os testes e como trabalhei com uma equipe de bots. No fim você vai ver como uma regra simples de "fazer com menos" guiou quase todas as escolhas.

Demonstração do Caderno de Amigos: registrar uma conversa pelo post-it e ver o quadro se reorganizar

Todos os dados que aparecem nas imagens e no vídeo são fictícios.

Primeiro a dor, depois (talvez) o produto

Hoje muita gente foca em construir algo pra vender antes de resolver os próprios problemas. Eu quis fazer o caminho contrário: começar por uma dor minha, de verdade, e usar no dia a dia antes de pensar em qualquer outra coisa.

Por isso o projeto tem dois objetivos, nessa ordem:

  1. Uso pessoal. Manter contato com os meus amigos. Só vira produto se o uso mostrar valor.
  2. Aprendizado. Praticar Django e Python de perto, com decisões registradas e entregas pequenas e verificáveis.

Isso muda bastante coisa. O ICP (o perfil de quem usa) hoje sou eu mesmo. Lá na frente, se fizer sentido, seria quem quer manter as amizades mas esquece de chamar. Pricing e pesquisa de mercado ficaram pausados de propósito.

E a métrica de sucesso também é pessoal: depois de 4 semanas de uso, abrir o app 3 vezes ou mais por semana e registrar mais contatos do que antes.

A ideia: lembrar sem cobrar

O maior risco de um app desses é virar mais uma fonte de culpa. Se toda vez que eu abrir aparecer uma lista vermelha de "atrasados" com "47 dias sem falar", eu paro de abrir na segunda semana.

Então a regra de produto foi clara: nada de "atrasado" em vermelho e nada de contagem de dias. O app não é um painel de vendas: não tem placar e não tem culpa.

Na prática, funciona assim:

  • Cada amigo tem o seu ritmo. "Falar a cada 2 semanas", "a cada 1 mês", "a cada 3 meses". Dá pra cadastrar só com o nome, e o ritmo já vem como "falar a cada 1 mês".
  • Um quadro de post-its em 4 colunas: "Faz um tempinho", "Essa semana", "Em dia" e "Falei agora".
  • Falou com alguém? Botão "falei agora". Você escolhe se foi mensagem, ligação ou encontro e escreve uma nota rápida. A nota aparece no post-it da pessoa, pra você lembrar do assunto da próxima vez.

Quadro do Caderno de Amigos com post-its nas 4 colunas

Repara que o post-it mostra "1 mês" ou "2 semanas", que é o ritmo escolhido, e nunca "faltam 3 dias" ou "atrasado há 12 dias".

Registrando um contato: tipo (mensagem, ligação ou encontro) e nota rápida

Além do quadro, o MVP tem grupos opcionais (um amigo pode estar em vários), aniversário com o selo "Aniversário hoje", histórico de contatos no perfil de cada amigo, conta própria com e-mail e senha, e layouts pra desktop e celular. Ficaram de fora: notificação por e-mail ou push, integração com WhatsApp, sugestão de mensagem por IA e recuperação de senha.

Perfil de um amigo com ritmo, aniversário e histórico

A régua de simplicidade

Antes de falar das decisões, preciso falar da regra que guiou quase todas elas. Projeto pessoal tem a mania de acumular complexidade. Então virou princípio, registrado num ADR (um documento curto que registra uma decisão de arquitetura, com contexto e consequências):

Se dá pra fazer com menos regra, menos estado ou menos biblioteca, faz assim.

Toda issue passou por essa régua. Três exemplos de como ela cortou coisa:

  • "Essa semana" com janela fixa de 7 dias. A primeira versão usava uma janela dinâmica, min(7, ceil(f/4)), que variava com o ritmo de cada amigo. Ela adicionava complexidade sem valor proporcional. Hoje a coluna é simples: entra quem tem de 1 a 7 dias até o ritmo vencer.
  • Botão em vez de arrastar e soltar. O plano inicial era arrastar o post-it pra registrar contato, usando o SortableJS. Saiu a biblioteca e entrou o botão "falei agora", com o mesmo nome da coluna. Menos dependência e uma ação mais explícita.
  • Sem Redis, RabbitMQ nem Celery. É comum um projeto Django vir com cache, fila e worker. Mas com um usuário e poucos dados, cada peça a mais é mais um serviço pra configurar e manter. O MVP é só Django e Postgres. Se um dia vierem as notificações, o plano é começar com um comando do Django (manage.py enviar_lembretes) agendado no Coolify. Celery só entra se aparecerem muitas tarefas em segundo plano.

Um efeito colateral curioso da janela fixa: quem tem ritmo de até 9 dias nunca aparece em "Em dia". Vai direto de "Falei agora" pra "Essa semana". Isso está documentado como comportamento esperado, não como bug.

Decisão 1: a coluna é calculada na leitura, não salva no banco

Essa foi a decisão técnica que eu mais gostei de tomar.

A primeira ideia que vem na cabeça é salvar a coluna de cada amigo no banco. O problema é que a coluna depende do tempo. Um amigo que hoje está "Em dia" vai estar em "Essa semana" daqui a alguns dias, sem ninguém mexer em nada. Se a coluna estivesse salva, ela ia ficar velha sozinha e pedir uma tarefa diária pra atualizar.

Ou seja: não guardar no banco o que o tempo muda.

Pelo mesmo motivo, o model Friend não tem um campo last_contact_at. Se tivesse, seriam duas fontes da verdade: ao apagar um contato (o app tem "desfazer"), esse campo poderia ficar errado. A data do último contato vem direto da tabela de contatos, numa consulta só:

friends.annotate(last_contact=Max("contacts__date")) 

Um índice em (friend, date) na tabela de contatos mantém isso rápido, e o annotate evita o problema de N+1 (fazer uma consulta extra pra cada amigo).

A regra das colunas mora num service do Django, o BoardService. A API devolve o campo pronto (column: "this_week") e o Vue só desenha. Calcular no front iria duplicar a regra e dificultar os testes. Esta é a parte central do service:

def calculate_column(self, friend: Friend, last_contact: datetime | None) -> Column: if last_contact is None: return Column.OVERDUE # "Faz um tempinho"  days_since_last_contact = self.get_days_since_contact(last_contact) if days_since_last_contact <= self.DAYS_JUST_TALKED: # hoje ou ontem  return Column.JUST_TALKED # "Falei agora"  cadence_in_days = self.get_cadence_in_days(friend) days_until_next_contact = cadence_in_days - days_since_last_contact if days_until_next_contact <= 0: return Column.OVERDUE if days_until_next_contact <= self.DAYS_THIS_WEEK: # janela fixa de 7 dias  return Column.THIS_WEEK # "Essa semana"  return Column.ON_TRACK # "Em dia" 

Repara que o nome interno da coluna é OVERDUE, mas o que o usuário vê é "Faz um tempinho". O código pode ser direto, a interface é que precisa ser gentil.

Outro detalhe: o ritmo fica salvo do jeito que a pessoa escolheu, em dois campos (cadence_amount e cadence_unit), e só vira dias na hora da conta (semana = 7, mês = 30, ano = 365). Se eu guardasse só "30 dias", a tela de edição perderia o "1 mês".

Decisão 2: um container só, com sessão do Django e sem CORS

A arquitetura inicial previa dois serviços no Coolify: um pro backend Django e outro pro frontend Vue. Parece organizado, mas traz junto configuração de CORS, cookies entre origens diferentes e dois deploys pra coordenar.

A decisão foi uma imagem Docker só, com um Dockerfile em duas etapas:

  1. Uma etapa com Node compila o Vue com o Vite.
  2. A imagem final, de Python, serve a API do Django e os arquivos compilados do Vue pelo WhiteNoise, com Gunicorn.

O Postgres continua como um serviço separado.

Como frontend e API ficam na mesma origem (mesmo domínio e porta), a autenticação ficou bem mais simples. Em vez de JWT, usei a sessão do Django com SessionAuthentication do DRF:

  • O Vue faz login em POST /api/login/ e recebe um cookie de sessão.
  • Em toda requisição que altera dados, ele lê o cookie csrftoken e manda no header X-CSRFToken. A proteção contra CSRF já vem pronta no Django.
  • Nenhum token fica no localStorage, não tem token de renovação pra gerenciar e o logout é imediato.

E nada de configurar CORS (uma dor de cabeça de muita gente). JWT só se justificaria aqui com app de celular, vários domínios ou integração com outros sistemas. Se isso aparecer, dá pra adicionar autenticação por token em paralelo, só nos endpoints que precisarem.

Vale dizer que o código continua separado (backend/ e frontend/, com a API JSON como fronteira). Juntar no deploy não juntou as responsabilidades, e separar em dois serviços depois não exige reescrever nada.

Decisão 3: TDD desde o primeiro commit

TDD (escrever o teste antes do código) foi regra desde o início, e eu sou um fã assíduo dessa prática. No backend uso pytest + pytest-django com Postgres real (sem mock de banco) e o relógio congelado com time-machine. No frontend, Vitest com a API mockada.

O teste que eu mais gosto de mostrar é o do fuso horário. A regra conta dias locais: um contato às 23:30 de terça em São Paulo tem que contar como terça. Só que, em UTC, esse instante já é 02:30 de quarta.

Por isso os contatos são salvos como DateTimeField (vira timestamptz no Postgres), com USE_TZ = True e TIME_ZONE = "America/Sao_Paulo". O banco guarda o instante em UTC, sem ambiguidade, e o service converte pra data local com timezone.localdate() antes de comparar com o "hoje" de São Paulo. E o teste trava isso:

@pytest.fixture(autouse=True) def freeze_clock() -> Iterator[None]: """Keep each board test at Thursday 10:00 in São Paulo.""" with time_machine.travel(datetime(2026, 10, 22, 13, tzinfo=UTC), tick=False): yield def test_tuesday_night_contact_counts_as_tuesday_in_sao_paulo(self) -> None: # Arrange  friend = Friend(cadence_amount=1, cadence_unit=Friend.CadenceUnit.MONTH) last_contact = datetime(2026, 10, 21, 2, 30, tzinfo=UTC) # terça, 23:30 em SP  # Act  column = BoardService().calculate_column(friend, last_contact) # Assert  assert column == Column.ON_TRACK 

Se o cálculo usasse a data em UTC, esse contato cairia na quarta e a conta dos dias mudaria. Esse é o tipo de bug que aparece só de madrugada, em produção, e que um teste de três linhas pega antes.

Os outros testes seguem a mesma linha: cada caso de ritmo, a transição de "Falei agora" pra "Essa semana" nos ritmos de até 9 dias, o "desfazer" devolvendo o amigo pra coluna anterior e o acesso a um amigo de outra conta retornando 404. No CI rodam as duas suítes, mais ruff, mypy e vue-tsc.

A stack

De forma resumida:

  • Backend: Python, Django 5, Django REST Framework e PostgreSQL 16.
  • Frontend: Vue 3 (SPA), TypeScript em modo strict, Vite e Tailwind CSS.
  • Deploy: um único container Docker no Coolify, self-hosted, com Gunicorn e WhiteNoise. O Postgres roda num serviço separado.
  • Qualidade: pytest, Vitest, Ruff, mypy e vue-tsc, com dependências Python gerenciadas pelo uv.

Escolhi o Coolify em vez da Vercel porque eu queria praticar Django "de verdade", com processo rodando, admin e comandos de gerenciamento. Na Vercel o Django rodaria como função serverless, com cold start e banco externo.

O processo: eu e uma equipe de bots

Essa parte foi um experimento à parte. Em vez de fazer tudo sozinho, montei uma pequena equipe de bots, cada um com um papel:

  • Designer: criou a identidade visual de caderno físico (pauta, espiral, abas, post-its, carimbos) e fez o polimento das telas, com revisão visual de cada entrega.
  • PM: cuidou do produto e do roadmap: escopo do MVP, critérios de aceite (como "a lista nunca usa vermelho nem a palavra 'atrasado'") e a métrica de sucesso.
  • Tech lead: quebrou o trabalho em cards no Plane e conduziu PRs pequenos e revisados, cada um com o que muda e como foi verificado.

As decisões importantes continuaram comigo. Todas viraram ADRs curtos no repositório, com contexto, problema, decisão e consequências. Quando eu quero lembrar por que algo é do jeito que é, está escrito lá.

Foram os cards CRM-1 a CRM-29, todos concluídos. Os últimos PRs dão uma boa ideia de como era o ritmo: ajustes visuais devolvendo o que um commit anterior tinha desconfigurado (#36), a prévia ao vivo do amigo novo no cadastro (#37), o GIF e o vídeo no README (#38 e #39) e um polimento final do Designer (#40), verificado com typecheck, 159 testes no frontend, build e prints de antes e depois de cada item.

Até o vídeo de 38 segundos do README foi feito assim, em Remotion, a partir de uma gravação do app com amigos fictícios. O roteiro tinha critérios de aceite próprios, por exemplo: tudo que aparece no vídeo precisa existir na main.

O que vem agora: usar de verdade

Agora vem a parte difícil: usar de verdade e conseguir manter contato com as pessoas que eu gosto.

O app está no ar desde 30/10, e a decisão foi não construir nada novo por 4 semanas, até 27/11. A métrica que definimos (abrir 3x ou mais por semana e registrar mais contatos que antes) nunca foi medida. Sem isso, qualquer feature nova é chute.

No meio do caminho, em 13/11, tem um checkpoint. O maior risco do produto é eu simplesmente esquecer de abrir, já que o app não manda aviso nenhum. Se isso acontecer, um aviso leve por e-mail, uma vez por semana, sobe pro topo da fila. E mesmo assim o primeiro teste vai ser o mais barato: duas semanas de lembrete manual no calendário antes de escrever qualquer código.

Conclusão

Resumindo tudo até aqui:

  • Comecei por uma dor minha, não por uma ideia pra vender.
  • A regra de produto é lembrar sem cobrar: sem vermelho, sem contagem de dias.
  • A régua de simplicidade cortou janela dinâmica, arrastar e soltar, Redis e Celery.
  • O que o tempo muda não vai pro banco: a coluna é calculada na leitura.
  • Mesma origem simplificou tudo: um container, sessão do Django, CSRF e zero CORS.
  • TDD desde o início pegou até o contato das 23:30.

Foi um projeto pequeno, mas que me ensinou muito sobre tomar decisão por escrito e fazer com menos. Se você também usa ADRs ou uma regra parecida no seu projeto pessoal, conta nos comentários: qual decisão de "fazer mais simples" mais te ajudou?

O código está aberto pra quem quiser clonar, estudar ou contribuir: github.com/stephanto-dev/caderno-de-amigos. Lá também tem o vídeo completo de 38 s.

E pra quem quiser testar, a versão de teste está em cadernodeamigos.stephanto.com.br.

Obrigado por ler até aqui! Learn in public.