De sonho a grafo: o que aprendi modelando metas, progresso e colaboração com React Flow e Three.js
Como construí o Praxis: uma única árvore de objetivos renderizada como mapa 2D, lista e constelação 3D, com progresso derivado, ciclos bloqueados, colaboração e IA opcional.
- react flow
- three.js
- supabase
- grafos
- produto digital
A interface bonita só funcionaria se o modelo por baixo fosse chato e consistente
O Praxis nasceu de uma ideia visual: transformar objetivos grandes em uma estrutura navegável, indo de sonhos a ações concretas.
O produto tem mapa, lista e uma visualização em 3D.
Esse tipo de projeto convida a começar pela animação.
Eu fiz o contrário na parte que mais importa: comecei pela árvore.
Porque três visualizações diferentes só são úteis se todas estiverem representando o mesmo estado.
Um objetivo não é uma tela; é um nó
A modelagem base pode ser entendida como um grafo hierárquico.
type GoalNode = {
id: string
ownerId: string
parentId?: string
level: 'dream' | 'vision' | 'goal' | 'project' | 'task'
title: string
completed: boolean
}
A interface decide como renderizar.
O domínio não deveria ter entidades chamadas MapCard ou ConstellationSphere.
Isso me permite ter:
mesmos nós
├─ lista
├─ React Flow
└─ Three.js
Quando apresentação entra no modelo, cada nova visão exige duplicar verdade.
Progresso é dado derivado, não campo para editar manualmente
Se uma meta possui tarefas filhas, o progresso pode ser calculado a partir da árvore.
Uma versão simples:
function progress(node: GoalNode, children: GoalNode[]): number {
if (children.length === 0) {
return node.completed ? 1 : 0
}
return children.reduce((sum, child) => sum + child.progress, 0)
/ children.length
}
Na prática, ponderações podem mudar conforme o produto.
O princípio é evitar dois estados que podem discordar:
pai.progress = 70%
filhos concluídos = 20%
Se o valor pode ser derivado de fatos mais básicos, eu prefiro derivar.
Mover um nó pode corromper a árvore
Drag-and-drop parece uma interação visual, mas muda estrutura de domínio.
Se eu permitir qualquer conexão:
A → B → C
↑ ↓
└───────┘
criei um ciclo.
Agora funções recursivas podem não terminar e a hierarquia deixa de fazer sentido.
Antes de trocar parentId, o sistema precisa validar pelo menos:
- o destino não é o próprio nó;
- o destino não é descendente do nó movido;
- o nível de origem pode se conectar ao nível de destino;
- usuário tem permissão nos dois lados.
Uma checagem simplificada de ciclo:
async function wouldCreateCycle(nodeId: string, newParentId: string) {
let current = newParentId
while (current) {
if (current === nodeId) return true
current = await findParentId(current)
}
return false
}
A linha arrastada no React Flow pode parecer só UX. Por baixo, ela é mutação de grafo.
React Flow resolveu interação; não regra de negócio
React Flow oferece uma base excelente para nodes, edges, handles e interações.
Mas eu tento manter uma divisão clara:
React Flow diz: usuário tentou conectar A em B
Domínio diz: essa relação é permitida?
Não uso o fato de a biblioteca conseguir desenhar uma edge como prova de que a edge deveria existir.
Essa separação é essencial para não deixar uma decisão visual virar regra implícita.
Posicionamento automático e posicionamento manual podem coexistir
Mapas hierárquicos precisam de layout automático para começar organizados.
Mas o usuário também quer mover nós.
Se o layout recalcula tudo depois de cada edição, ele destrói o trabalho manual.
Uma solução é separar:
type NodeLayout = {
nodeId: string
autoX: number
autoY: number
manualX?: number
manualY?: number
}
Render:
const x = manualX ?? autoX
const y = manualY ?? autoY
A máquina cria uma primeira organização. O usuário pode assumir controle de pontos específicos.
Esse padrão de "default calculado + override explícito" aparece em muitos produtos configuráveis.
A constelação 3D não precisava virar um segundo produto
A visualização em Three.js usa a mesma árvore.
A diferença é a função de layout.
No projeto, os nós principais são distribuídos em uma esfera usando uma estratégia baseada em Fibonacci, e descendentes podem ser organizados em cones orientados ao redor do pai.
A ideia conceitual para pontos na esfera é distribuir ângulos de maneira uniforme, evitando concentração nos polos.
O importante arquiteturalmente é:
dados da árvore
↓
layout 2D ou layout 3D
↓
renderizador
Não:
banco do mapa
banco da lista
banco da constelação
Visualização é projeção.
Carregar Three.js sob demanda protege a experiência principal
3D traz peso de bundle e GPU que a lista não precisa pagar.
Então faz sentido carregar a experiência 3D apenas quando o usuário entra nela.
const Constellation = lazy(() => import('./Constellation'))
A sintaxe pode variar conforme o stack. O objetivo é o mesmo: feature opcional não deve cobrar imposto de performance de todo usuário.
Isso vale para editor rico, mapa, gráfico pesado, vídeo e qualquer biblioteca grande.
Colaboração em tempo real é sincronização mais autorização
Dois usuários vendo o mesmo mapa é fácil de demonstrar.
O problema real é:
- ambos podem editar?
- qual mapa podem assinar?
- uma mudança recebida ainda é válida?
- o que acontece com edições simultâneas?
Com Supabase Realtime, canais podem distribuir mudanças e RLS pode participar da autorização.
Eu penso na camada assim:
mutação válida
↓
PostgreSQL
↓
evento
↓
canal daquele mapa
↓
clientes autorizados
Banco continua sendo fonte de verdade.
Realtime acelera propagação; não substitui persistência e invariantes.
Presença e colaboração não são a mesma coisa
Mostrar "Yago está online" é Presence.
Sincronizar uma mudança de nó é evento de estado.
Resolver dois usuários editando o mesmo campo é conflito.
São problemas diferentes.
Essa separação evita achar que WebSocket automaticamente produz colaboração correta.
Dependendo do nível de concorrência, pode ser necessário versionamento otimista, locks leves ou CRDT. Nem todo produto precisa ir tão longe.
A sofisticação deve seguir conflito real observado.
WOOP e planos "se-então" entram como método, não como autoridade da IA
O Praxis também se inspira em abordagens como WOOP — Wish, Outcome, Obstacle, Plan — e implementation intentions, os planos do tipo "se X acontecer, então farei Y".
Isso é interessante porque transforma objetivo abstrato em gatilho mais concreto.
No software, uma representação poderia ser:
type IfThenPlan = {
if: string
then: string
}
A IA pode ajudar o usuário a formular um plano menos vago.
Mas eu não quero que ela declare o que a pessoa "deve querer".
Ela serve melhor como ferramenta de reflexão e decomposição do que como autoridade sobre a vida do usuário.
IA opcional é uma decisão de produto importante
Adicionar coach de IA poderia facilmente dominar toda a interface.
Eu prefiro que o núcleo continue útil sem ele.
mapa de objetivos funciona sem IA
progresso funciona sem IA
colaboração funciona sem IA
IA melhora reflexão quando solicitada
Isso reduz dependência de provider e, mais importante, evita que uma feature probabilística vire pré-requisito para ações básicas.
Assinatura também é estado de domínio
Stripe resolve grande parte da infraestrutura de pagamento recorrente, mas o produto ainda precisa traduzir eventos de billing para capacidade.
subscription active
→ features premium habilitadas
subscription past_due
→ política de tolerância
subscription canceled
→ regra de downgrade
Eu evito espalhar checagens como:
if (plan === 'pro')
por toda a interface.
Prefiro capabilities:
canUseAiCoach(user)
canCreateSharedMaps(user)
Plano comercial pode mudar. Capacidade de produto é o que o código realmente precisa saber.
O insight maior: um bom modelo permite várias interfaces sem duplicar verdade
Praxis reforçou uma coisa que aparece em sistemas muito diferentes:
interface é uma visão do domínio, não o domínio em si.
Quando a árvore está bem modelada:
- lista vira uma projeção;
- mapa vira outra;
- 3D vira outra;
- analytics de progresso vira outra.
Quando a modelagem está errada, cada nova tela vira um novo estado para sincronizar.
Essa diferença é enorme.
A visualização 3D é a parte que chama atenção em uma demo.
A parte que faz o produto continuar funcionando depois da demo é uma estrutura de dados bem menos glamourosa, com regras para pai, filho, nível, permissão e progresso.
Eu gosto desse contraste porque ele resume bastante do que considero engenharia de produto: o visual pode ser ousado; a fonte de verdade precisa ser tediosamente confiável.